Merge origin/ump-v4 into main

Combine Flash CAN updates, signal processing and HOPE pin fixes with current Modbus/GAS, STM32 ports and CCS release exports. Resolve protocol, documentation and Qt conflicts; deduplicate Android JNI sources and include gauge/IGBT sources in host builds.
This commit is contained in:
2026-11-07 01:32:27 +03:00
136 changed files with 8828 additions and 821 deletions
+29
View File
@@ -0,0 +1,29 @@
import time # Монотонные часы и короткое ожидание позволяют фоновому Python-коду регулярно освобождать GIL для обработчиков Qt.
class WorkBudget: # Длинный расчёт добровольно уступает процессор, чтобы поток интерфейса не ждал GIL после каждого вызова Qt.
def __init__(self, cancelled=None): # Необязательное threading.Event отменяет устаревшее задание без зависимости общего алгоритма от Qt.
self.cancelled = cancelled # Событие принадлежит одному заданию; его установку проверяем до и после уступки интерфейсу.
self.deadline = time.perf_counter() + .002 # Две миллисекунды непрерывной работы ограничивают конкуренцию с короткими Python-обработчиками GUI.
def __call__(self): # Рабочие проходы вызывают контрольную точку примерно раз в тысячу элементов, без проверки часов на каждом отсчёте.
if self.cancelled is not None and self.cancelled.is_set(): # Запрос отмены должен действовать даже до истечения бюджета вычислений.
raise InterruptedError("Фоновое задание отменено") # Вызывающий worker освобождает ресурсы и не публикует частичный результат.
if time.perf_counter() < self.deadline: # Несколько быстрых контрольных точек не должны создавать лишние ожидания.
return # Продолжаем текущую небольшую порцию вычислений до исчерпания её бюджета.
if self.cancelled is None: # Неотменяемая подготовка также обязана оставлять время главному потоку.
time.sleep(.001) # Пауза в одну миллисекунду освобождает GIL; sleep(0) не гарантирует времени для цепочки обработчиков Qt.
elif self.cancelled.wait(.001): # Ожидание освобождает GIL и немедленно заканчивается при отмене вместо полной паузы.
raise InterruptedError("Фоновое задание отменено") # Не продолжаем вычисление после отмены, полученной во время уступки интерфейсу.
self.deadline = time.perf_counter() + .002 # Новый бюджет начинается после ожидания, чтобы не накапливать пропущенные интервалы.
def work_items(items, checkpoint): # Оборачиваем полный последовательный проход без копирования и без потери исходных элементов.
if checkpoint is None: # Обычные синхронные вызовы сохраняют прежнее поведение и не получают искусственных задержек.
yield from items # Передаём значения в неизменном порядке без счётчика контрольных точек.
return # Ветвь без фонового бюджета не выполняет проверки часов.
for index, item in enumerate(items): # Счётчик определяет небольшие порции независимо от типа исходного контейнера.
if index % 1024 == 0: # Не более 1024 элементов между проверками ограничивают задержку отмены и удержание GIL.
checkpoint() # Уступаем время интерфейсу либо прерываем устаревшее задание.
yield item # Отдаём каждый исходный элемент ровно один раз, сохраняя точность и порядок измерений.
checkpoint() # Последняя проверка не допускает публикации результата, отменённого в конце короткого блока.
+7 -7
View File
@@ -34,16 +34,16 @@ def decode(raw_id: int, data: bytes, *, ide: bool = True,
pm=1 if frame.from_device else 0, priority=0,
)
result = pc.Decoded(pid, len(data), bytes(data), ide=ide, rtr=rtr,
protocol="BALZAM 167", device_label=frame.device_name,
message_label=("BALZAM данные" if frame.from_device
protocol="BALZAM 167", device_label=frame.sender_name, # Все CAN ID 167 подписываются источником: команды — TMS, ответы — именем устройства из таблицы адресов.
message_label=("BALZAM Heartbeat" if frame.is_heartbeat else "BALZAM данные" if frame.from_device # Тип сообщения явно выделяет heartbeat по адресу и маске, сохраняя имя отправителя.
else "BALZAM команды"))
result.summary = frame.summary
result.fields = [
("Протокол", "BALZAM 167 legacy eCAN"),
("Узел", "%d — %s" % (frame.device, frame.device_name)),
("Направление", frame.route),
("Отправитель", frame.device_name if frame.from_device else "ПМ"),
("Получатель", "ПМ" if frame.from_device else frame.device_name),
("Отправитель", frame.sender_name), # Используем ту же подпись источника, что в маршруте и имени кадра любого устройства 167.
("Получатель", frame.recipient_name), # Показываем адресата отдельно, сохраняя имя периферийного узла в поле «Узел».
("Начальный адрес", "0x%04X" % frame.start_address),
("Маска трёх слов", "0b%s" % format(frame.present_mask, "03b")),
]
@@ -51,10 +51,10 @@ def decode(raw_id: int, data: bytes, *, ide: bool = True,
for item in frame.registers:
result.fields.append((
"0x%04X %s" % (item.address, item.name or "регистр"),
"0x%04X (%d)" % (item.value, item.signed_value),
"0x%04X (%d)" % (item.value, item.value if frame.is_heartbeat else item.signed_value), # Счётчик секунд и идентификаторы heartbeat беззнаковые, в отличие от технологических int16.
))
if not frame.from_device and item.address == 0x7F:
result.fields.append(("Команды ПМ", frame.command_description(item.value)))
if item.address == 0x7F:
result.fields.append(("Активные биты Commands", frame.command_description(item.value)))
if frame.present_mask == 0:
result.warnings.append("В адресном слове не отмечен ни один регистр")
return result
+50 -2
View File
@@ -13,15 +13,59 @@
from __future__ import annotations
import random
import math
from datetime import datetime
from . import can_transport as tr
from protocan import protocan as pc
from protocan.gas_167 import REGISTERS
#: Типы сообщений, которые генератор выдаёт по кругу случайно.
KINDS = ("pulse", "analog", "modbus", "gas", "status", "error", "discrete")
class UmpDemoSource:
"""Two simulated UMP nodes using the assigned global GAS map.
Call next_frames every 250 ms: four words per node per tick and a
heartbeat every second. Device Type=3, Device=5/6 are demo addresses,
not discovery results or a claim about firmware 167/v1 support.
"""
def __init__(self) -> None:
self._tick = 0
self._seq = 0
def _frame(self, node: int, kind: int, body: int, data: bytes) -> bytes:
self._seq = (self._seq + 1) & 0xFF
identifier = pc.ProtoCanId.build(1, 1, 3, node, kind, body)
return tr.Frame(seq=self._seq, flags=tr.FLAG_IDE,
can_id=identifier, data=data).encode()
def next_frames(self) -> list[bytes]:
"""Return encoded bridge frames; one full map takes eight seconds."""
packets = []
# Show LOAD state immediately, then walk the entire dictionary.
start = (124 + self._tick * 4) % len(REGISTERS)
for node, key in ((5, "ump1_id"), (6, "ump2_id")):
if self._tick % 4 == 0:
packets.append(self._frame(
node, pc.MsgType.PULSE, 0,
bytes([(self._tick // 4) & 0xFF])))
values = []
for entry in REGISTERS[start:start + 4]:
offset = entry["modbus"]
value = int(2000 + 700 * math.sin(self._tick / 12 + offset / 8 + node))
command = 0x80 if (self._tick // 32) % 2 == 0 else 0x40
value = {82: 1000, 124: 8, 125: 0, 126: node, 127: command}.get(offset, value)
values.append(value.to_bytes(2, "little"))
packets.append(self._frame(
node, pc.MsgType.GENERAL_ADDRESS_SPACE,
REGISTERS[start][key], b"".join(values)))
self._tick += 1
return packets
class BusDemoSource:
"""@brief Источник демонстрационных кадров шины.
@@ -58,11 +102,15 @@ class BusDemoSource:
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
for _ in range(cnt))
elif kind == "gas":
adr = self._rng.randint(0, 0x400)
# Exercise the shared named map in the ordinary demo too.
dev_type, dev_id = 3, self._rng.choice((5, 6))
key = "ump1_id" if dev_id == 5 else "ump2_id"
count = self._rng.randint(1, 4)
adr = REGISTERS[self._rng.randrange(len(REGISTERS) - count + 1)][key]
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id,
pc.MsgType.GENERAL_ADDRESS_SPACE, adr)
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
for _ in range(self._rng.randint(1, 4)))
for _ in range(count))
elif kind == "status":
now = datetime.now()
cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.BROADCAST,
+10 -1
View File
@@ -32,6 +32,8 @@ from collections import deque
from dataclasses import dataclass, field
from itertools import chain
from typing import Iterable
from protocan import protocan as pc
from protocan.gas_167 import REGISTERS
#: Распространённые скорости UART для подключения платы и RS485-приёмника.
UART_BAUDS = (
@@ -596,7 +598,14 @@ class BoardDemoSource:
"""
#: Идентификаторы, по которым генератор идёт по кругу.
IDS = ((True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF))
IDS = (
(True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF),
# Assigned UMP map, simulated to demonstrate the common GAS decoder.
(True, pc.ProtoCanId.build(1, 1, 3, 5, pc.MsgType.GENERAL_ADDRESS_SPACE,
REGISTERS[124]['ump1_id'])),
(True, pc.ProtoCanId.build(1, 1, 3, 6, pc.MsgType.GENERAL_ADDRESS_SPACE,
REGISTERS[124]['ump2_id'])),
)
def __init__(self, rng: random.Random | None = None) -> None:
self._rng = rng or random.Random()
+147
View File
@@ -0,0 +1,147 @@
"""Проверяемые настройки физических CAN-адаптеров без зависимости от Qt.""" # Общая модель используется транспортами и редактором настроек.
from __future__ import annotations # Отложенная оценка объединений типов сохраняет импорт в сборке Python 3.8 для Windows 7.
from dataclasses import dataclass # Неизменяемые значения исключают смену режима во время работающего сеанса.
FD_LENGTHS = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) # Таблица DLC стандарта CAN FD переводит четырёхбитный код в число байтов.
@dataclass(frozen=True) # Полный тайминг передаётся в драйвер одной согласованной структурой.
class CanTiming: # Сегменты измеряются в квантах времени CAN, BRP задаёт делитель тактовой частоты.
prop_seg: int = 1 # Квант компенсации задержки распространения входит в TSEG1.
phase_seg1: int = 12 # Вместе с PROP_SEG даёт TSEG1=13 для обычного тайминга 48 МГц.
phase_seg2: int = 2 # Два кванта после точки выборки дают суммарно 16 квантов на бит.
sjw: int = 1 # Один квант ограничивает коррекцию при ресинхронизации.
brp: int = 6 # 48 МГц / 6 / 16 соответствует скорости 500 кбит/с.
def validate(self, cap, bitrate: int) -> None: # Проверяем ограничения, реально объявленные выбранным каналом.
if cap.fclk_can <= 0 or cap.brp_min < 1 or self.brp < 1: # Нулевая частота или делитель означают повреждённый ответ либо ошибочный ручной ввод.
raise ValueError("Некорректная частота CAN или нулевой BRP") # Не допускаем деления на ноль при диагностике скорости.
tseg1 = self.prop_seg + self.phase_seg1 # Контроллер ограничивает суммарный сегмент перед выборкой.
if self.prop_seg < 0 or self.phase_seg1 < 1: # Нулевая задержка допустима, но фазовый сегмент обязан существовать.
raise ValueError("PROP_SEG ≥ 0, PHASE_SEG1 ≥ 1") # Не отправляем в USB недопустимые сегменты.
if not cap.tseg1_min <= tseg1 <= cap.tseg1_max or not cap.tseg2_min <= self.phase_seg2 <= cap.tseg2_max: # Пределы различаются для номинальной и быстрой фазы.
raise ValueError("Сегменты тайминга вне диапазона адаптера") # Сообщаем об аппаратном ограничении до запуска канала.
if not 1 <= self.sjw <= min(cap.sjw_max, self.phase_seg2): # SJW не должен превышать второй фазовый сегмент.
raise ValueError("SJW превышает предел адаптера или PHASE_SEG2") # Исключаем некорректную ресинхронизацию.
if not cap.brp_min <= self.brp <= cap.brp_max or (self.brp - cap.brp_min) % max(1, cap.brp_inc): # Делитель учитывает шаг аппаратного регистра.
raise ValueError("BRP вне диапазона или шага адаптера") # Не округляем вручную заданный делитель.
actual = cap.fclk_can / (self.brp * (1 + tseg1 + self.phase_seg2)) # Единицы результата — бит/с; дополнительный квант относится к SYNC_SEG.
if abs(actual - bitrate) > max(1, bitrate * 0.0001): # Допуск 0,01 % позволяет записать 83 333 вместо периодической дроби.
raise ValueError(f"Тайминг задаёт {actual:g} бит/с вместо {bitrate}") # Статус и запись захвата не должны сообщать ложную скорость.
def calculate_timing(cap, bitrate: int, sample_point: float = 87.5) -> CanTiming: # Подбираем тайминг по частоте и пределам канала вместо фиксированной таблицы 48 МГц.
if cap.fclk_can <= 0 or not 1 <= cap.brp_min <= cap.brp_max <= 65536 or cap.tseg1_min < 1 or cap.tseg2_min < 1: # Ограничиваем также объём поиска при повреждённом ответе USB.
raise ValueError("Адаптер вернул некорректные пределы тайминга") # Не выдаём мусорные аппаратные поля за рабочую конфигурацию.
if bitrate <= 0 or not 50 <= sample_point <= 95: # Процент выборки ограничен практически полезным диапазоном.
raise ValueError("Скорость должна быть положительной, точка выборки — 50…95 %") # Не запускаем поиск с бессмысленными параметрами.
best = None # Сохраняем наиболее точное приближение скорости и точки выборки.
for brp in range(cap.brp_min, cap.brp_max + 1, max(1, cap.brp_inc)): # Рассматриваем только допустимые делители аппаратной частоты.
total = round(cap.fclk_can / (brp * bitrate)) # Число квантов в бите включает синхронизацию и оба сегмента.
if total < 3: # Минимум нужен для SYNC_SEG и двух ненулевых фазовых сегментов.
continue # Этот делитель не позволяет получить корректную структуру бита.
lower, upper = max(cap.tseg2_min, total - 1 - cap.tseg1_max), min(cap.tseg2_max, total - 1 - cap.tseg1_min) # Допустимый TSEG2 одновременно ограничивается обоими аппаратными сегментами.
if lower > upper: # Выбранное число квантов не раскладывается на допустимые TSEG1 и TSEG2.
continue # Другой делитель может дать ту же точную скорость при корректных сегментах.
tseg2 = min(upper, max(lower, round(total * (1 - sample_point / 100)))) # Выбираем ближайшую достижимую точку внутри пересечения аппаратных ограничений.
tseg1 = total - 1 - tseg2 # Оставшийся бюджет квантов принадлежит сегменту перед выборкой.
if not cap.tseg1_min <= tseg1 <= cap.tseg1_max: # Проверяем аппаратные пределы до вычисления оценки.
continue # Не пытаемся отправлять неподдерживаемую комбинацию сегментов.
error = abs(cap.fclk_can / (brp * total) - bitrate) / bitrate # Относительная ошибка скорости важнее ошибки точки выборки.
score = (error, abs((1 + tseg1) / total * 100 - sample_point), -total) # При равенстве предпочитаем больше квантов для точной синхронизации.
if best is None or score < best[0]: # Сравнение кортежей сохраняет установленный приоритет критериев.
best = (score, CanTiming(0, tseg1, tseg2, 1, brp)) # PROP_SEG=0 допустим в gs_usb: драйвер использует суммарный TSEG1.
if best is None or best[0][0] > 0.0001: # Не разрешаем скрытое округление к другой скорости шины.
raise ValueError(f"Адаптер не может точно установить {bitrate} бит/с") # Пользователь должен выбрать достижимую скорость.
best[1].validate(cap, bitrate) # Одинаково проверяем автоматический и ручной путь установки.
return best[1] # Возвращаем параметры, готовые для управляющего USB-запроса.
@dataclass(frozen=True) # Настройки SLCAN фиксируются до отправки первой команды конфигурации.
class SlcanOptions: # Различия команд выбираются явным профилем прошивки.
profile: str = "lawicel" # Lawicel использует подтверждения CR/BEL и команду L для пассивного режима.
loopback: bool = False # Команда l доступна только у прошивок, реализующих это расширение Lawicel.
retransmit: bool | None = None # None сохраняет настройку прошивки; A0/A1 применяются только для CANable.
timestamps: bool | None = None # None не записывает EEPROM Lawicel и оставляет текущее состояние Z.
acceptance_code: int | None = None # Регистр M задаётся вместе с маской и только у Lawicel.
acceptance_mask: int | None = None # Биты маски имеют семантику прошивки Lawicel, а не экранного фильтра ID.
btr: str = "" # Четыре HEX-цифры BTR0/BTR1 включают нестандартную скорость через команду s.
command_timeout_ms: int = 500 # Полсекунды ограничивает ожидание подтверждения отдельной команды.
fd: bool = False # Расширенные команды d/D/b/B поддерживает профиль CANable 2.0.
data_bitrate: int = 2_000_000 # В документированной CANable 2.0 доступны быстрая фаза 2 или 5 Мбит/с.
def commands(self, bitrate: int, listen_only: bool) -> list[str]: # Формируем весь план до открытия COM, чтобы ошибка не меняла аппаратное состояние.
if self.profile not in ("lawicel", "canable", "canable2") or not 10 <= self.command_timeout_ms <= 10000: # Ограничиваем профиль и тайм-аут 10 мс…10 с.
raise ValueError("Неверный профиль SLCAN или тайм-аут команды") # Произвольные команды через настройки не исполняются.
if self.loopback and (listen_only or self.profile != "lawicel"): # Для SLCAN нужны отдельные команды открытия l и L.
raise ValueError("Loopback требует профиль Lawicel и отключённый режим только приёма") # Не подменяем молча выбранный пользователем режим.
rates = {10000: "S0", 20000: "S1", 50000: "S2", 100000: "S3", 125000: "S4", 250000: "S5", 500000: "S6", 1000000: "S8"} # Общая часть стандартных скоростей задана спецификацией ASCII-команд.
rates[800000 if self.profile == "lawicel" else 750000] = "S7" # Одинаковая команда означает разные скорости в двух семействах прошивок.
if self.profile == "canable2": # Дополнительная скорость описана только для CANable 2.0.
rates[83333] = "S9" # Целое значение соответствует округлённым 83,333 кбит/с.
if self.btr: # Ручной тайминг имеет приоритет над выбором стандартного Sn.
if self.profile != "lawicel" or len(self.btr) != 4 or any(c not in "0123456789abcdefABCDEF" for c in self.btr): # Разрешаем ровно два байта регистров SJA1000.
raise ValueError("BTR0/BTR1: четыре HEX-цифры, только профиль Lawicel") # Исключаем внедрение дополнительных строк в команду.
speed = "s" + self.btr.upper() # Прошивка сама интерпретирует регистры тайминга своего контроллера.
btr0, btr1 = int(self.btr[:2], 16), int(self.btr[2:], 16) # В классическом Lawicel SJA1000 работает от генератора 16 МГц.
actual = 16_000_000 / (2 * ((btr0 & 63) + 1) * (3 + (btr1 & 15) + ((btr1 >> 4) & 7))) # SJA1000 делит частоту на 2·BRP и число квантов SYNC+TSEG1+TSEG2.
if abs(actual - bitrate) > max(1, bitrate * 0.0001): # Сохраняем честную скорость в статусе и захвате даже при ручном BTR.
raise ValueError(f"BTR задаёт {actual:g} бит/с при 16 МГц; выберите эту скорость подключения") # Не маскируем рассогласование ручных регистров и выбранного битрейта.
else: # Обычный путь требует наличия точной скорости в выбранном профиле.
speed = rates.get(bitrate) # Не используем S7 от другого семейства адаптеров.
if speed is None: # Произвольная скорость без BTR не имеет команды в этом профиле.
raise ValueError(f"Профиль {self.profile} не поддерживает {bitrate} бит/с") # Сообщаем профиль вместе с отклонённой скоростью.
result = [speed] # Настройка начинается после закрытия прежнего канала командой C.
if self.profile != "lawicel": # CANable выбирает режим до открытия O и не использует L.
result.append("M1" if listen_only else "M0") # Явно сбрасываем сохранённый silent при активном подключении.
if self.retransmit is not None: # Сохраняем поведение прошивки, пока пользователь не выбрал A0/A1.
result.append("A1" if self.retransmit else "A0") # Отключение повторов даёт однократную попытку передачи.
elif self.retransmit is not None: # Буква A у Lawicel имеет другую семантику и не должна отправляться как CANable.
raise ValueError("Управление повторами A0/A1 доступно только в профиле CANable") # Предотвращаем смешивание диалектов.
if self.timestamps is not None: # Команда Z может сохраняться прошивкой в EEPROM.
if self.profile != "lawicel": # CANable не документирует поддержку этого управления.
raise ValueError("Временные метки Z0/Z1 доступны только в профиле Lawicel") # Не игнорируем пользовательскую настройку.
result.append("Z1" if self.timestamps else "Z0") # Состояние задаётся только при явном выборе.
if self.acceptance_code is not None or self.acceptance_mask is not None: # Фильтр задаётся атомарной парой регистров.
if self.profile != "lawicel" or any(v is None or not 0 <= v <= 0xFFFFFFFF for v in (self.acceptance_code, self.acceptance_mask)): # Каждый регистр содержит ровно 32 бита.
raise ValueError("Фильтр Lawicel требует код и маску в диапазоне 00000000…FFFFFFFF") # M0/M1 CANable не должны трактоваться как фильтры.
result.extend((f"M{self.acceptance_code:08X}", f"m{self.acceptance_mask:08X}")) # Порядок байтов регистра соответствует документации Lawicel.
if self.fd: # Для FD требуется документированное расширение CANable 2.0.
if self.profile != "canable2" or self.data_bitrate not in (2_000_000, 5_000_000): # Y2/Y5 — единственные поддержанные этой прошивкой скорости данных.
raise ValueError("CAN FD требует CANable 2.0 и скорость данных 2 либо 5 Мбит/с") # Не открываем несовместимый канал.
result.append("Y2" if self.data_bitrate == 2_000_000 else "Y5") # Настраиваем быструю фазу до открытия CAN.
result.append("l" if self.loopback else "L" if listen_only and self.profile == "lawicel" else "O") # Выбираем одну команду открытия без опасного возврата к активному режиму.
return result # Последовательный транспорт проверит ответ на каждый этап там, где прошивка выдаёт подтверждения.
@dataclass(frozen=True) # Флаги запуска Candle хранятся вместе с таймингами и настройкой терминатора.
class CandleOptions: # Все расширения проверяются по маске возможностей конкретного канала.
loopback: bool = False # Приём собственных кадров нужен для диагностики контроллера.
one_shot: bool = False # Одна попытка передачи вместо автоматических повторов при отсутствии ACK.
triple_sample: bool = False # Тройная выборка применяется только при аппаратной поддержке.
hardware_timestamp: bool | None = None # None сохраняет автоматическое включение аппаратных меток.
termination: bool | None = None # None сохраняет состояние встроенного терминатора, не меняя топологию сети.
sample_point: float = 87.5 # Целевой процент номинального бита для автоматического расчёта.
timing: CanTiming | None = None # Ручные сегменты заменяют автоматический расчёт номинального тайминга.
fd: bool = False # Разрешает FD только после проверки возможности и настройки быстрой фазы.
data_bitrate: int = 2_000_000 # Скорость быстрой фазы задаётся в бит/с.
data_sample_point: float = 80.0 # Для быстрой фазы обычно нужна более ранняя точка выборки.
data_timing: CanTiming | None = None # Ручной тайминг быстрой фазы проверяется по расширенным пределам.
bus_errors: bool = True # Сохраняем сообщения об ошибках на шине при наличии управляемого режима.
def flags(self, listen_only: bool, features: int) -> int: # Маска gs_usb объединяет независимо выбираемые режимы.
flags = (1 if listen_only else 0) | (2 if self.loopback else 0) | (4 if self.triple_sample else 0) | (8 if self.one_shot else 0) | (256 if self.fd else 0) # Значения битов заданы протоколом gs_usb.
if self.hardware_timestamp is True: # Явный выбор требует возможности аппаратных временных меток.
flags |= 16 # Бит 4 означает HW_TIMESTAMP.
elif self.hardware_timestamp is None: # Автоматический режим включает метки только при поддержке устройства.
flags |= features & 16 # Отсутствие возможности не является ошибкой автоматической настройки.
if self.bus_errors: # Старые прошивки выдают ошибки всегда и не требуют этого флага.
flags |= features & 4096 # Управляемый BERR_REPORTING включается лишь при объявленном бите 12.
elif not features & 4096: # У старых прошивок нет подтверждаемого выключателя сообщений об ошибках.
raise ValueError("Прошивка не поддерживает отключение сообщений об ошибках CAN") # Не показываем фактически невыполнимую настройку как применённую.
if flags & ~features: # Ни один явный запрос не должен тихо исчезнуть из маски запуска.
raise ValueError(f"Канал не поддерживает выбранные режимы: 0x{flags & ~features:X}") # Маска помогает сопоставить отказ с диагностикой адаптера.
if self.termination is not None and not features & 2048: # Встроенный переключаемый терминатор объявляется битом 11.
raise ValueError("Адаптер не поддерживает программное управление терминатором") # Внешний резистор программой не переключается.
return flags # Готовая маска передаётся в CANDLE_DEVMODE_START.
+21 -1
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
from . import can_transport as tr
from protocan import protocan as pc
from protocan.gas_167 import assigned_metadata
from . import balsam_can
#: Расшифровка поля LEC регистра CAN_ESR.
@@ -64,8 +65,27 @@ def decode_frame(frame: tr.Frame) -> pc.Decoded:
decoded_balsam = balsam_can.decode(
frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
if decoded_balsam is not None:
decoded_balsam.fields.append(("Автоопределение", "Balsam 167: идентификатор legacy CAN"))
return decoded_balsam
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
result = pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
if (frame.ide and not frame.rtr
and result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE
and len(frame.data) <= 8 and len(frame.data) % 2 == 0
and result.registers is not None):
addresses = [address for address, _value in result.registers] or [result.id.body]
entries = [assigned_metadata(address) for address in addresses]
if all(entries) and len({entry['node'] for entry in entries}) == 1:
node = entries[0]['node'] - 4
result.protocol = "УМП / ProtoCAN GAS (назначенные ID)"
result.device_label = "УМП %d" % node
result.message_label = "GAS · УМП %d" % node
result.fields.append(("Автоопределение",
"УМП %d: все GAS ID входят в назначенную карту; поддержка прошивкой не подтверждена" % node))
return result
result.fields.append(("Автоопределение", "Разбор ProtoCAN; профиль прибора не определён"))
if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE:
result.summary += " · профиль/узел не определены — выберите протокол вручную"
return result
dec = pc.Decoded(id=pc.ProtoCanId.parse(frame.can_id), dlc=len(frame.data),
data=frame.data, ide=frame.ide, rtr=frame.rtr)
+2 -1
View File
@@ -10,6 +10,7 @@ from dataclasses import dataclass
from protocan.legacycan import PROJECTS, LegacyCanFormat, decode
from .can485_board import BoardFrame, SdLogImport, parse_sd_log
from .trend_limits import MAX_TOTAL_POINTS
@dataclass
@@ -63,7 +64,7 @@ def _values(frame: BoardFrame):
def read_can_log(lines, *, reference_timestamp: float, capacity: int,
max_total_points: int = 1_000_000,
max_total_points: int = MAX_TOTAL_POINTS,
frame_limit: int = 100_000) -> tuple[list[CanLogChannel], SdLogImport]:
imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit)
channels = {}
+63 -5
View File
@@ -8,16 +8,43 @@ from pathlib import Path
from protocan import protocan as pc
from .can_bridge import decode_frame
PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"),
("devboard", "DevBoard_V1 / SETCAN"),
("deif", "DEIF XDi-192 / CANopen"))
PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"), # Автоматический разбор выбирает формат по кадру, не требуя ручного указания прибора.
("devboard", "DevBoard_V1 / SETCAN"), # Формат DevBoard сохраняет самостоятельный пункт для существующих мониторов платы.
("deif", "DEIF XDi-192 / CANopen"), # Прибор DEIF использует собственные правила CANopen и остаётся отдельным форматом.
("protocan", "ProtoCAN / GAS"), # Общий ProtoCAN разбирает кадры без ручного назначения карты технологических слов УМП.
("ump_balsam", "УМП / Balsam 167 (legacy CAN)"), # Старый Balsam имеет другой формат кадра и требует отдельного декодера.
("ump_protocan", "УМП / ProtoCAN GAS")) # Единый формат УМП отделяет выбор карты адресов и номера устройства от протокола.
UMP_MAPS = (("gas167", "167/v1 (текущая)"), # Действующая карта использует слова 0…127 и окно 0x5000…0x507F из профиля прошивки 167/v1.
("assigned", "Новые ID 5000/6000 (планируется)")) # Десятичные базы 5000 и 6000 назначены документацией, но ещё не реализованы прошивкой.
def decode_protocol(frame, protocol="auto"):
def decode_protocol(frame, protocol="auto", *, ump_map="gas167", ump_node=1): # Карта 167/v1 и УМП 1 служат исходным выбором единого формата; параметры карты и узла передаются по имени.
if protocol == "deif":
from .deif_xdi import decode
return decode(frame)
if protocol == "devboard":
if protocol == "ump_balsam":
from .balsam_can import decode
result = decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
if result is not None:
return result
result = pc.Decoded(pc.ProtoCanId.parse(frame.can_id), len(frame.data),
bytes(frame.data), ide=frame.ide, rtr=frame.rtr,
protocol="BALZAM 167", message_label="Не Balsam 167")
result.summary = "УМП / Balsam 167: CAN ID или формат кадра не соответствует протоколу"
result.warnings.append(result.summary)
return result
if protocol in ("ump1_gas167", "ump2_gas167"):
from .ump_can import decode_gas167
return decode_gas167(frame, node=1 if protocol == "ump1_gas167" else 2)
if protocol == "ump_protocan": # Новые элементы интерфейса используют единый формат, а старые ключи выше сохраняют прежний разбор.
if ump_map == "gas167": # Действующие локальные адреса сами не определяют номер УМП, поэтому он задаётся отдельно.
from .ump_can import decode_gas167 # Словарь 167/v1 подключается только при явном выборе этой карты.
return decode_gas167(frame, node=ump_node) # Один декодер разбирает технологические слова обоих УМП с выбранной подписью устройства.
if ump_map == "assigned": # Планируемая сквозная карта определяет УМП по десятичным диапазонам 5000…5127 и 6000…6127.
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) # Общий ProtoCAN уже содержит имена назначенных ID и не требует ручного номера УМП.
raise ValueError("Неизвестная карта адресов УМП: %s" % ump_map) # Ошибочный ключ карты не должен молча давать расшифровку другого профиля.
if protocol in ("devboard", "protocan", "ump_gas"):
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
return decode_frame(frame)
@@ -44,6 +71,36 @@ def parse_filter(text):
raise ValueError("Введите CAN ID в hex, например 701 или 0x701, 201") from None
def parse_gas_filter(text): # GAS ID вводятся десятичными числами либо в HEX с явным префиксом 0x.
if not text.strip(): # Пустое поле не добавляет ограничений к независимому фильтру CAN ID.
return None # Значение None обозначает отсутствие условия, а не пустой набор разрешённых адресов.
try: # Ошибки ввода превращаются в понятное сообщение общей панели монитора.
parts = [part.strip() for part in text.replace(";", ",").split(",")] # Поддерживаем те же разделители списка, что и у существующего CAN-фильтра.
addresses = {int(part, 16 if part.lower().startswith("0x") else 10) for part in parts} # Число 5000 означает назначенный десятичный GAS ID, а 0x5000 — адрес локального окна.
if any(address < 0 or address > 0xFFFF for address in addresses): # Поле Body ProtoCAN содержит 16-битный адрес слова GAS в диапазоне 0…65535.
raise ValueError() # Выход за пределы адресного пространства отклоняется вместе с остальными ошибками формата.
return addresses # Повторяющиеся адреса объединяются без изменения логики отбора кадров.
except ValueError: # Неполные, отрицательные и некорректно записанные значения не должны проходить фильтр молча.
raise ValueError("Введите GAS ID: 5000, 5127 или 0x5000; диапазон 0…65535") from None # Сообщаем систему счисления и допустимые границы непосредственно рядом с фильтром.
def matches_filters(identifier, data, extended, rtr=False, *, can_ids=None, gas_ids=None): # Оба условия объединяются по И, а значения внутри одного списка — по ИЛИ.
if can_ids is not None and identifier not in can_ids: # Быстрый отбор по CAN ID выполняется до декодирования полезной нагрузки.
return False # Несовпадение любого включённого ограничения исключает кадр.
if gas_ids is None: # При выключенном или пустом GAS-фильтре сохраняется прежний отбор CAN-кадров всех форматов.
return True # CAN ID уже проверен либо не ограничен.
if not extended or rtr or len(data) > 8 or len(data) % 2: # GAS использует расширенный кадр данных с полными 16-битными словами и DLC не больше 8 байт.
return False # STD, RTR и повреждённая нагрузка не обозначают достоверные GAS-регистры.
if pc.ProtoCanId.parse(identifier).msg_type != pc.MsgType.GENERAL_ADDRESS_SPACE: # Совпадение младших битов ID другого типа сообщения не является совпадением адреса GAS.
return False # Остальные протоколы остаются видимыми только при выключенном GAS-условии.
decoded = pc.decode(identifier, data, ide=extended, rtr=rtr) # Общий декодер определяет все регистры кадра и отличает их от текстового GAS-отклика.
if not data: # Пустой кадр данных GAS представляет запрос по начальному адресу Body.
return decoded.id.body in gas_ids # Запрос учитывается только для указанного в нём адреса.
if not decoded.registers or any(address > 0xFFFF for address, _value in decoded.registers): # Отклоняем служебный текст и пакет, выходящий за конец 16-битного пространства.
return False # Такие кадры не содержат допустимого набора регистров для адресного фильтра.
return any(address in gas_ids for address, _value in decoded.registers) # Совпадение второго, третьего или четвёртого слова выбирает весь исходный кадр.
class CanSession:
"""All frames spool to disk; retain recent frames and unique-frame totals.
@@ -74,6 +131,7 @@ class CanSession:
self.unique_frames[key] = (row, previous[1] + 1 if previous else 1)
if self.output is not None:
csv.writer(self.output, delimiter=";").writerow(row)
return key # Возвращаем ключ уникального кадра, чтобы интерфейс обновлял только поступившие записи без повторного обхода сессии.
def start(self, directory):
directory = Path(directory) / datetime.now().strftime("session_%Y%m%d_%H%M%S_%f")
+24 -2
View File
@@ -57,6 +57,29 @@ def decode_identity(words):
raise ValueError("Некорректные данные версии: " + str(error)) from error
def validate_image_device(meta, expected_product, expected_target="TMS320F28335"): # Сопоставляет назначение в паспорте с настройкой загрузчика без обращения к подключённой плате.
product, target = meta.get('product', 'не указана'), meta.get('target', 'не указан') # Отсутствующее назначение тоже даёт понятный отказ, включая Flash RS, где KeyError не является пользовательской ошибкой.
if product == expected_product and target == expected_target: # Совпадение обоих полей сохраняет прежнее разрешение на дальнейшие проверки BIN.
return # Не заменяет проверку размера, SHA-256 и формата, которую вызывающий код выполняет отдельно.
models = {'Balsam-166-PM35': 'Бальзам-166 ПМ35', 'Balsam-167-PM35': 'Бальзам-167 ПМ35'} # Человекочитаемые названия соответствуют двум моделям из списка «Модель платы».
selected = models.get(str(expected_product), str(expected_product)) # Для других устройств сохраняет исходное обозначение, не выдавая неизвестную модель за 166 или 167.
actual = models.get(str(product), str(product)) # Показывает назначение из паспорта; ошибочный тип поля тоже не мешает вывести причину отказа.
differences = [] # Собирает только действительно различающиеся признаки, чтобы общий процессор не выглядел причиной отказа.
if product != expected_product: # Различие моделей проверяется независимо от процессора, который может совпадать у разных плат.
differences.append('модели платы') # Указывает на расхождение обозначений 166/167 даже при одинаковом TMS320F28335.
if target != expected_target: # Отдельно выявляет несовместимый процессор без ошибочного обвинения модели платы.
differences.append('процессора') # Дополняет причину вторым признаком, только если его значение отличается.
raise ValueError( # Блокирует загрузку с подробным объяснением; метаданные не доказывают аппаратное назначение содержимого BIN.
'Назначение прошивки не совпадает с выбранным устройством. Загрузка заблокирована.\n' # Сразу сообщает результат проверки и его влияние на загрузку.
f'Выбрано в программе: {selected} ({expected_product}); процессор {expected_target}.\n' # Связывает ожидаемые значения с пользовательской настройкой, а не с результатом опроса платы.
f'В паспорте файла: {actual} ({product}); процессор {target}.\n' # Показывает фактические значения полей паспорта рядом с ожидаемыми.
f'Причина: несовпадение {" и ".join(differences)}. Модель подключённой платы этой проверкой не определялась.\n' # Разделяет установленное расхождение метаданных и неизвестное состояние оборудования.
f'Для платы {selected} выберите BIN с паспортом для этой модели и процессора {expected_target}. ' # Предлагает комплект для нужной платы, сохраняя выбранную пользователем модель.
f'Если эта прошивка собрана для {selected}, проверьте назначение в настройках сборки и заново сформируйте выпуск с паспортом. ' # Объясняет исправление ошибочной маркировки сборки без подмены значений готового паспорта.
'Поле «Модель платы» меняйте, только если в программе выбрана не ваша плата.' # Не предлагает выбирать 167 ради обхода сообщения при реальной плате 166.
) # Завершает единое сообщение, одинаковое для EEPROM BIN и Flash RS BIN.
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
max_image_size=MAX_IMAGE_SIZE):
@@ -82,8 +105,7 @@ def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None
if (meta['schema'] != 1 or meta['identity_contract'] != 1
or meta['identity_register_count'] != REGISTER_COUNT):
raise ValueError("Неизвестный формат паспорта")
if meta['product'] != expected_product or meta['target'] != expected_target:
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
validate_image_device(meta, expected_product, expected_target) # При несовпадении показывает выбранные и паспортные модель/процессор с причиной отказа и способом исправления.
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
+11 -9
View File
@@ -8,6 +8,7 @@ import io
import math
import json
from datetime import datetime
from functools import lru_cache # Таблица достижимых частот строится один раз для каждого аппаратного таймера MCU.
from set_devices.waveform import Waveform, generate as interpolate, load_recipe as load_interpolated_recipe
from set_devices.signal_reconstruction import dac12
@@ -15,13 +16,14 @@ from set_devices.signal_reconstruction import dac12
# TIM6 clock of the supported STM32F407 emulator firmware. The device protocol
# does not currently expose its timer clock; keep this hardware profile explicit.
F407_TIMER_HZ = 72_000_000
F407_SAMPLE_RATES = tuple(sorted({
rate
for divisor in range(1, math.isqrt(F407_TIMER_HZ) + 1)
if F407_TIMER_HZ % divisor == 0
for rate in (divisor, F407_TIMER_HZ // divisor)
if 1 <= rate <= 1_000_000
}))
@lru_cache(maxsize=16) # Небольшой кэш хранит аппаратные профили без повторного перебора делителей при построении сигнала.
def timer_sample_rates(timer_hz, sample_max=1_000_000): # Частоты задаются целочисленным делением TIM6 выбранного MCU, единицы обоих параметров — Гц.
if timer_hz <= 0 or sample_max <= 0: # Нулевые и отрицательные аппаратные параметры не описывают рабочий таймер.
raise ValueError("Частота таймера и предел выборок должны быть положительными") # Ошибка профиля выявляется до формирования таблицы DAC.
return tuple(sorted({rate for divisor in range(1, math.isqrt(timer_hz) + 1) if timer_hz % divisor == 0 for rate in (divisor, timer_hz // divisor) if 1 <= rate <= sample_max})) # Парные делители охватывают все точные частоты без перебора каждого значения до тактовой частоты.
F407_SAMPLE_RATES = timer_sample_rates(F407_TIMER_HZ) # Старый публичный экспорт сохраняет прежние результаты для профиля F407 72 МГц.
def nearest_f407_sample_rate(rate):
@@ -29,9 +31,9 @@ def nearest_f407_sample_rate(rate):
return min(F407_SAMPLE_RATES, key=lambda candidate: (abs(candidate - rate), candidate))
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Прозрачно передаём выбранный настольный решатель общему генератору.
if method != "samples":
return interpolate(points, sample_rate, vref, method, degree)
return interpolate(points, sample_rate, vref, method, degree, backend=backend) # Расчёт остаётся в нативном порте; обработчик готовых отсчётов не требует МНК.
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
if not math.isfinite(vref) or not 0 < vref <= 3.6:
File diff suppressed because one or more lines are too long
+28 -11
View File
@@ -2,28 +2,45 @@
from __future__ import annotations
import math
from itertools import islice # Блоки по 1024 элемента ограничивают задержку отмены длинных измерений без изменения порядка суммирования.
from set_devices.plot_ticks import tick_precision # Общая точность подписей позволяет различать малые деления времени на фоне большого абсолютного смещения.
def time_label(milliseconds):
def time_label(milliseconds, step=None): # Необязательный шаг в миллисекундах повышает точность только для подписей шкалы; остальные вызовы сохраняют прежний вид.
"""Readable time without rounding nanosecond divisions to zero."""
magnitude = abs(milliseconds)
precision = tick_precision(milliseconds, step, 6) if step is not None else 6 # Шесть значащих цифр остаются стандартом; малый шаг требует дополнительных разрядов до точности double.
if magnitude < .001:
return f"{milliseconds * 1e6:.6g} нс"
return f"{milliseconds * 1e6:.{precision}g} нс" # В миллисекунде 10^6 наносекунд; выбранная точность сохраняет различие соседних малых делений.
if magnitude < 1:
return f"{milliseconds * 1000:.6g} мкс"
return f"{milliseconds * 1000:.{precision}g} мкс" # В миллисекунде 1000 микросекунд; шаг и координата масштабируются одинаково, поэтому число значащих цифр не меняется.
if magnitude < 1000:
return f"{milliseconds:.6g} мс"
return f"{milliseconds / 1000:.6g} с"
return f"{milliseconds:.{precision}g} мс" # Средние интервалы остаются в исходных миллисекундах с точностью, достаточной для текущего шага шкалы.
return f"{milliseconds / 1000:.{precision}g} с" # Деление на 1000 переводит миллисекунды в секунды без потери нужных значащих цифр.
def measurements(points):
values = [value for _, value in points if math.isfinite(value)]
def _measurement_items(items, check_cancelled): # Добавляем необязательную отмену к любому проходу статистики, сохраняя все исходные элементы и их порядок.
if check_cancelled is None: # Обычные вызовы измерений сохраняют прежний контракт без дополнительных проверок отмены.
yield from items # Передаём исходную последовательность без копирования всего ряда или численных преобразований.
return # Не создаём блоки для синхронных измерений, которым не передано средство отмены.
source = iter(items) # Один итератор обеспечивает последовательный обход без повторов на границах блоков.
while True: # Продолжаем обработку до исчерпания исходной последовательности или исключения отмены.
check_cancelled() # Новый жест навигации прерывает даже этап среднего, СКЗ или поиска экстремумов.
block = tuple(islice(source, 1024)) # Не более 1024 элементов между проверками удерживают задержку отмены малой на многомиллионной записи.
if not block: # Пустой блок означает, что все исходные отсчёты уже переданы численной операции.
return # Последняя проверка отмены выполнена до завершения прохода.
yield from block # Сохраняем порядок элементов для прежней точности math.fsum и поиска пересечений.
def measurements(points, check_cancelled=None): # Необязательный обработчик отмены расширяет API, не меняя существующие вызовы с одним аргументом.
values = [value for _, value in _measurement_items(points, check_cancelled) if math.isfinite(value)] # Проверяем отмену и при отборе конечных амплитуд, включая полностью нечисловой ряд.
if not values:
return None
low, high = min(values), max(values)
mean = math.fsum(value / len(values) for value in values)
low = min(_measurement_items(values, check_cancelled)) # Точный минимум вычисляется по всем конечным значениям с ограниченной задержкой отмены.
high = max(_measurement_items(values, check_cancelled)) # Точный максимум сохраняет прежнюю семантику, в том числе порядок равных значений.
mean = math.fsum(value / len(values) for value in _measurement_items(values, check_cancelled)) # Отмена проверяется внутри суммирования; порядок и нормировка слагаемых остаются прежними.
scale = max(abs(low), abs(high))
rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in values) / len(values)) if scale else 0.
rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in _measurement_items(values, check_cancelled)) / len(values)) if scale else 0. # Прерываем длинный расчёт СКЗ без потери прежней защиты от переполнения.
# Hysteresis rejects small excursions around the midpoint. Frequency is
# only reported after two complete rising crossings, never from one edge.
middle = (low + high) / 2
@@ -32,7 +49,7 @@ def measurements(points):
crossings = []
previous = None
if high > low:
for stamp, value in points:
for stamp, value in _measurement_items(points, check_cancelled): # Проверяем отмену и при полном проходе поиска частоты, сохраняя исходную гистерезисную обработку пропусков.
if value < middle - hysteresis:
armed = True
if armed and previous and previous[1] < middle <= value and stamp > previous[0]:
+76 -5
View File
@@ -1,19 +1,31 @@
"""Pixel-column envelope for drawing; measurement samples are never changed."""
import math
from bisect import bisect_left, bisect_right
from .background_work import work_items # Фоновая подготовка длинных временных массивов должна уступать GIL во время заполнения и проверки порядка.
class PointTimes: # Представление координат X для bisect без параметра key, отсутствующего в Python 3.8 сборки Win7.
def __init__(self, points): # Принимаем индексируемые пары (X, Y), порядок которых проверяется вызывающим кодом.
self.points = points # Храним ссылку на исходные точки, чтобы не копировать большой ряд при каждом изменении окна.
def __len__(self): # Предоставляем bisect длину исходного ряда для определения границ двоичного поиска.
return len(self.points) # Получаем размер за постоянное время без перебора измерений.
def __getitem__(self, index): # Извлекаем одну координату по индексу, выбранному алгоритмом двоичного поиска.
return self.points[index][0] # В паре (X, Y) индекс 0 обозначает время или частоту; значения Y не участвуют в сравнении.
class IndexedTimes(list):
"""Random-access timestamps, with ordering validated once when loaded."""
def __init__(self, points):
super().__init__(points)
self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x)
for i, x in enumerate(self))
def __init__(self, points, checkpoint=None): # Необязательная контрольная точка включает кооперативную подготовку только для фоновых файловых заданий.
super().__init__(work_items(points, checkpoint)) # Заполняем полный массив, регулярно освобождая GIL при наличии фонового бюджета.
self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x) # Конечность и порядок проверяются по всем исходным меткам без прореживания.
for i, x in work_items(enumerate(self), checkpoint)) # Второй длинный проход также уступает интерфейсу и реагирует на отмену.
def viewport_samples(timestamps, values, left, right, neighbours=False, budget=None):
"""Select only the viewport in logarithmic time for indexed records."""
if isinstance(timestamps, (list, tuple, range)) and getattr(timestamps, "ordered", True):
if getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Проверенные индексируемые представления со смещением X также допускают двоичный поиск без копирования меток времени.
start = bisect_left(timestamps, left)
stop = bisect_right(timestamps, right)
if neighbours:
@@ -37,6 +49,10 @@ def pixel_envelope(timestamps, values, left, right, width):
and zooming retain lines crossing the viewport boundaries.
"""
width = max(1, int(width))
index = getattr(values, "envelope_index", None) # Подготовленный файловый ряд содержит индекс точных экстремумов для быстрого выбора экранных столбцов.
if index is not None and index.count == len(values) and getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Индекс применим к неизменённым значениям и проверенным координатам, включая ленивое смещение короткой развёртки.
yield from index.points(timestamps, left, right, width) # Выдаём не более четырёх конечных точек на пиксель плюс соседей для отсечения краёв.
return # Исключаем повторное линейное сканирование миллионов измерений при каждом изменении масштаба.
scale = width / (right - left) if right > left else 1.
bucket = None
first = low = high = last = None
@@ -73,6 +89,61 @@ def pixel_envelope(timestamps, values, left, right, width):
yield from emit()
def render_envelope(timestamps, values, left, right, width): # Экранная огибающая ограничивает плотный упорядоченный ряд двумя экстремумами на логический пиксель, сохраняя исходные измерения отдельно.
width = max(1, int(width)) # Вызывающий виджет передаёт логическую ширину области: увеличение DPI не должно умножать число почти совпадающих вершин.
ordered = getattr(timestamps, 'ordered', isinstance(timestamps, (list, tuple, range))) # Двоичный поиск и экранные столбцы применимы только к проверенному порядку временных координат.
if not ordered: # Неупорядоченный поток нельзя безопасно переставлять по экранным столбцам или обрезать двоичным поиском.
yield from pixel_envelope(timestamps, values, left, right, width) # Сохраняем прежнюю последовательность и разрывы для нестандартного источника без предположения об упорядоченности.
return # Новый ограниченный экранный режим не меняет семантику неупорядоченных данных.
count = min(len(timestamps), len(values)) # Общая длина исключает обращения за границу при несовпадающих размерах координат и амплитуд.
start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Находим точное число исходных отсчётов видимого окна без полного обхода файла.
if stop - start <= width: # При сильном увеличении все измерения помещаются в экранный бюджет и не требуют никакого сокращения.
for position in range(max(0, start - 1), min(count, stop + 1)): # Сохраняем по одному соседу для линий, пересекающих края видимого окна.
stamp, value = timestamps[position], values[position] # Извлекаем точную исходную пару, включая нечисловые амплитуды между конечными отсчётами.
yield (stamp, value) if math.isfinite(stamp) and math.isfinite(value) else None # Все конечные точки остаются без изменений, а NaN и бесконечности разрывают путь.
return # Разреженный зум не должен проходить через экстремумы столбцов и терять близкие исходные точки.
index = getattr(values, 'envelope_index', None) # Подготовленный файловый ряд позволяет выбирать экстремумы без повторного линейного обхода миллионов значений.
if index is not None and index.count == len(values): # Индекс допустим только для исходной неизменённой длины ряда.
yield from index.points(timestamps, left, right, width, extrema_only=True) # Сохраняем максимум два экстремума на столбец, два конца окна и два внешних соседа.
return # Быстрый запрос не должен дополнительно запускать линейный запасной путь.
columns = {} # Для неиндексированного ряда храним лишь пару экстремумов на экранный столбец, а не полную запись.
first_visible = last_visible = None # Дополнительно сохраняем два конца видимой линии для правильного соединения с внешними соседями.
segment = 0 # Каждый нечисловой отсчёт увеличивает номер непрерывного участка; разные номера запрещают соединять выбранные точки.
seen = False # Полностью нечисловое окно должно вернуть один разрыв, а пустая запись не создаёт искусственных точек.
scale = width / (right - left) if right > left else 1. # Масштаб переводит время в столбцы; совпадающие временные координаты не допускают деления на ноль.
for ordinal, (stamp, value) in enumerate(viewport_samples(timestamps, values, left, right, neighbours=True)): # Запасной путь читает каждый исходный отсчёт окна ровно один раз и поэтому также сохраняет одиночные пики.
seen = True # Фиксируем наличие хотя бы одного конечного либо нечислового исходного измерения.
if not math.isfinite(stamp) or not math.isfinite(value): # Пропуски не участвуют в экстремумах, но должны разделять любые выбранные точки по разные стороны.
segment += 1 # Номер участка переносит информацию о пропуске без хранения каждого маркера плотного ряда.
continue # Нечисловая амплитуда не имеет экранной координаты кривой.
column = -1 if stamp < left else width if stamp > right else min(width - 1, int((stamp - left) * scale)) # Соседи вынесены в два отдельных столбца за пределами видимой области.
point = (ordinal, stamp, value, segment) # Исходный порядок и номер участка позволяют вернуть экстремумы без ложных соединений через NaN.
pair = columns.get(column) # В каждом экранном столбце сохраняются только точные минимум и максимум.
if pair is None: # Первый конечный отсчёт одновременно задаёт обе роли нового столбца.
columns[column] = [point, point] # Два совпадающих индекса позднее объединяются в одну вершину.
else: # Дальнейшие измерения меняют только действительно достигнутые экстремумы.
if value < pair[0][2]: # Строгое сравнение сохраняет первое вхождение равного минимума.
pair[0] = point # Одиночный отрицательный выброс остаётся в экранной огибающей.
if value > pair[1][2]: # Строгое сравнение сохраняет первое вхождение равного максимума.
pair[1] = point # Одиночный положительный выброс также остаётся видимым при любом сжатии.
if left <= stamp <= right: # Внешние соседи не подменяют начало и конец собственно видимых измерений.
first_visible = point if first_visible is None else first_visible # Первый конечный видимый отсчёт сохраняется один раз независимо от числа столбцов.
last_visible = point # Последний конечный видимый отсчёт обновляется до правого края окна.
selected = {point[0]: point for pair in columns.values() for point in pair} # Уникальные исходные индексы устраняют совпадение ролей без изменения времени или амплитуды.
for point in (first_visible, last_visible): # Всего две дополнительные конечные точки сохраняют соединения на краях видимого окна.
if point is not None: # Полностью нечисловое окно не имеет конечных краевых измерений.
selected[point[0]] = point # Повторное добавление совпавшего экстремума не увеличивает экранный бюджет.
previous_segment = 0 # Нулевой участок соответствует началу исходной последовательности до первого NaN.
for ordinal in sorted(selected): # Возвращаем выбранные точки в исходном порядке, включая совпадающие метки времени.
_, stamp, value, current_segment = selected[ordinal] # Экранная вершина сохраняет исходные координаты и номер непрерывного участка.
if current_segment != previous_segment: # Различные номера подтверждают хотя бы один NaN между выбранными конечными отсчётами.
yield None # Маркер разрыва запрещает рисовать линию через отсутствующие измерения.
yield stamp, value # Амплитуда и время возвращаются точно, без усреднения и сдвига коротких пиков.
previous_segment = current_segment # Следующая вершина сравнивается с участком предыдущего выбранного измерения.
if seen and (not selected or previous_segment != segment): # Сохраняем полностью нечисловое окно и разрыв после последней конечной точки.
yield None # Длинная последовательность хвостовых пропусков ограничивается одним экранным маркером.
def preview_window_count(requested, preview_count, original_count):
"""Translate a raw-sample window to a display-envelope window."""
if not original_count or original_count <= preview_count:
+174
View File
@@ -0,0 +1,174 @@
import math # Проверяем конечность измерений, чтобы пропуски не соединялись линиями и не участвовали в экстремумах.
from array import array # Храним индексы в компактных массивах вместо отдельных объектов Python для каждого узла дерева.
from bisect import bisect_left, bisect_right # Находим границы пиксельных столбцов двоичным поиском по упорядоченным меткам времени.
class EnvelopeIndex: # Индекс неизменяемого ряда позволяет выбирать точные экстремумы без повторного обхода миллионов отсчётов.
BLOCK_SIZE = 32 # Лист дерева содержит 32 отсчёта: крайние неполные блоки малы, а индекс занимает около 2,5 байта на отсчёт.
EMPTY = (-1, -1, -1, -1, 0) # Первые четыре поля обозначают индексы первого, минимального, максимального и последнего отсчёта; пятое считает пропуски.
def __init__(self, values, checkpoint=None): # Необязательная контрольная точка сохраняет отзывчивость Qt при первоначальном построении большого индекса.
self.values = values # Сохраняем исходные значения по ссылке; сам индекс не сокращает и не изменяет измерения.
self.count = len(values) # Запоминаем размер снимка для проверки пригодности индекса после очистки графика.
blocks = (self.count + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Округление вверх включает неполный последний блок записи.
self.leaves = 1 # Для обычного двоичного дерева число листьев должно быть степенью двух.
while self.leaves < blocks: # Добавляем пустые листья только до ближайшей степени двух, не удваивая размер без необходимости.
self.leaves *= 2 # Два дочерних узла соответствуют объединённому диапазону родителя.
self.nodes = tuple(array('q', [field]) * (2 * self.leaves) for field in self.EMPTY) # Пять знаковых 64-битных массивов содержат индексы, включая признак отсутствия -1.
for block in range(blocks): # Полный обход исходных значений нужен только при первоначальном построении индекса.
if checkpoint is not None and block % 32 == 0: # Тридцать два листа по 32 отсчёта дают контроль примерно через 1024 исходных значения.
checkpoint() # Отпускаем GIL и проверяем отмену между ограниченными порциями подготовки экстремумов.
start = block * self.BLOCK_SIZE # Переводим номер блока в начальный индекс исходного отсчёта.
self._store(self.leaves + block, self._scan(start, min(self.count, start + self.BLOCK_SIZE))) # Сводка листа сохраняет точные экстремумы и число нечисловых отсчётов.
for node in range(self.leaves - 1, 0, -1): # Строим родителей снизу вверх, когда сводки обоих детей уже готовы.
if checkpoint is not None and node % 1024 == 0: # Объединение большого дерева также делится на порции по 1024 узла.
checkpoint() # Не допускаем непрерывного удержания GIL после завершения чтения исходных измерений.
self._store(node, self._merge(self._load(2 * node), self._load(2 * node + 1))) # Родитель объединяет соседние диапазоны с сохранением временного порядка.
def _store(self, node, summary): # Записываем одну сводку в параллельные компактные массивы дерева.
for column, field in zip(self.nodes, summary): # Каждое поле имеет одинаковый номер узла во всех пяти массивах.
column[node] = field # Индексы ссылаются на исходный ряд, поэтому значения Y не дублируются в памяти.
def _load(self, node): # Читаем сводку готового узла без обращения ко всем его исходным отсчётам.
first, low, high, last, gaps = self.nodes # Разбираем пять массивов один раз, исключая создание генератора при каждом посещении узла дерева.
return first[node], low[node], high[node], last[node], gaps[node] # Возвращаем те же пять полей напрямую; тысячи запросов кадра не создают промежуточных итераторов.
def _scan(self, start, stop): # Сканируем полуинтервал только при построении листа либо на неполных краях запроса.
first = low = high = last = -1 # Отрицательный индекс означает, что конечное значение ещё не найдено.
gaps = 0 # Подсчёт пропусков позволяет проверять разрыв линии между выбранными экстремумами.
values, isfinite = self.values, math.isfinite # Локальные ссылки исключают повторный поиск атрибутов для каждого отсчёта неполного края блока.
low_value = high_value = 0. # Значения обоих экстремумов устанавливаются первым конечным отсчётом; ноль не участвует в сравнении до этого момента.
for index in range(start, stop): # Полуинтервал исключает stop и совпадает с правилами срезов Python.
value = values[index] # Читаем исходное измерение ровно один раз, сохраняя значение для обоих сравнений экстремумов.
if not isfinite(value): # NaN и бесконечности должны разрывать путь, а не менять масштаб или экстремумы.
gaps += 1 # Учитываем пропуск даже в диапазоне, где совсем нет конечных значений.
continue # Исключаем нечисловой отсчёт из поиска первого, последнего, минимального и максимального значений.
if first < 0: # Первый конечный отсчёт одновременно задаёт оба начальных экстремума.
first = low = high = index # Все три индекса указывают на одно измерение до появления следующих точек.
low_value = high_value = value # Сохраняем амплитуды начальных экстремумов, чтобы следующие отсчёты не читали их повторно из большого ряда.
else: # Для остальных отсчётов обновляем только реально изменившиеся экстремумы.
if value < low_value: # Строгое сравнение с сохранённой амплитудой сохраняет первый по времени минимум при равных значениях.
low = index # Запоминаем точный индекс минимального исходного значения.
low_value = value # Обновляем амплитуду вместе с индексом, исключая отдельное чтение минимума на следующей итерации.
if value > high_value: # Первый по времени максимум также остаётся стабильным при равенстве значений.
high = index # Запоминаем точный индекс максимального исходного значения.
high_value = value # Сохраняем амплитуду нового максимума для дальнейших сравнений без обращения к исходному контейнеру.
last = index # Последний конечный отсчёт нужен для правильного соединения соседних пиксельных столбцов.
return first, low, high, last, gaps # Сводка содержит только индексы и счётчик; измерения остаются в исходном ряду.
def _merge(self, before, after): # Объединяем сводки соседних диапазонов, расположенных в исходном временном порядке.
gaps = before[4] + after[4] # Число пропусков аддитивно и не зависит от расположения экстремумов.
if before[0] < 0: # Левый диапазон может быть пустым либо полностью состоять из пропусков.
return (*after[:4], gaps) # Сохраняем конечные точки правого диапазона и пропуски обоих диапазонов.
if after[0] < 0: # Пустой правый диапазон не меняет конечные точки левого диапазона.
return (*before[:4], gaps) # Добавляем только число пропусков справа.
low = after[1] if self.values[after[1]] < self.values[before[1]] else before[1] # При равных минимумах сохраняем более ранний отсчёт.
high = after[2] if self.values[after[2]] > self.values[before[2]] else before[2] # При равных максимумах также сохраняем более ранний отсчёт.
return before[0], low, high, after[3], gaps # Крайние точки берём из внешних границ объединённого диапазона.
def between(self, start, stop): # Возвращаем точную сводку произвольного диапазона индексов без полного сканирования ряда.
start, stop = max(0, start), min(self.count, stop) # Ограничиваем запрос существующими измерениями снимка.
if stop <= start: # Пустое или обратное окно не содержит ни точек, ни пропусков.
return self.EMPTY # Единый пустой результат упрощает объединение диапазонов на границах записи.
if not self.nodes[4][1] and isinstance(self.values, (list, tuple, array)): # Нулевой счётчик пропусков корня доказывает конечность всего неизменяемого ряда; обычные контейнеры допускают короткие срезы краёв.
return self._finite_between(start, stop) # Плотные записи без NaN обходятся без пяти-польных сводок и проверок конечности на каждом кадре.
first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Находим первый полностью включённый блок после левого неполного края.
last_block = stop // self.BLOCK_SIZE # Этот блок уже не входит целиком в запрос и задаёт начало правого края.
if first_block >= last_block: # Короткий запрос дешевле прочитать напрямую, чем обходить дерево.
return self._scan(start, stop) # Здесь читается не более двух неполных блоков исходных отсчётов.
before = self._scan(start, first_block * self.BLOCK_SIZE) # Получаем сводку левого неполного края, максимум из 31 отсчёта.
after = self._scan(last_block * self.BLOCK_SIZE, stop) # Получаем сводку правого неполного края, также максимум из 31 отсчёта.
left, right = self.leaves + first_block, self.leaves + last_block # Переводим границы полных блоков в номера листьев дерева.
while left < right: # За логарифмическое число шагов выбираем узлы, полностью покрывающие внутренний диапазон.
if left % 2: # Правый ребёнок слева включается отдельно, чтобы не захватить отсчёты перед запросом.
before = self._merge(before, self._load(left)) # Добавляем очередной диапазон после уже выбранной левой части.
left += 1 # Переходим к следующему соседнему узлу перед подъёмом к родителю.
if right % 2: # Правая исключённая граница позволяет включить её левого соседа отдельно.
right -= 1 # Перемещаем границу к последнему целиком включённому узлу текущего уровня.
after = self._merge(self._load(right), after) # Добавляем узел перед уже выбранной правой частью, сохраняя порядок.
left //= 2 # Поднимаем левую границу на уровень объединённых диапазонов.
right //= 2 # Правая граница поднимается на тот же уровень дерева.
return self._merge(before, after) # Соединяем выбранные части в одну точную сводку запрошенного полуинтервала.
def _finite_between(self, start, stop): # Быстрый запрос применяется только после проверки отсутствия NaN и бесконечностей во всём исходном ряду.
values = self.values # Сохраняем локальную ссылку на полные исходные амплитуды, не создавая их копию.
first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Левый неполный край заканчивается перед первым целиком включённым блоком.
last_block = stop // self.BLOCK_SIZE # Последний целый блок предшествует правому неполному краю запроса.
if first_block >= last_block: # Короткий диапазон занимает не более двух блоков по 32 отсчёта и не требует обхода дерева.
chunk = values[start:stop] # Временный срез содержит максимум 63 исходных амплитуды, независимо от полного размера лога.
return start, start + chunk.index(min(chunk)), start + chunk.index(max(chunk)), stop - 1, 0 # Встроенные операции находят точные первые экстремумы на C; первый, последний и нулевые пропуски уже известны.
low = high = start # Любой непустой конечный диапазон имеет допустимое начальное измерение на левой границе.
low_value = high_value = values[start] # Амплитуды экстремумов храним локально, чтобы не перечитывать их из большого массива после каждого узла.
for begin, end in ((start, first_block * self.BLOCK_SIZE), (last_block * self.BLOCK_SIZE, stop)): # Сканируем только два неполных края, каждый не длиннее 31 исходного отсчёта.
if begin < end: # Совпадение с границей блока исключает создание пустого среза и вызов min/max без элементов.
chunk = values[begin:end] # Короткий временный срез позволяет выполнить поиск экстремумов встроенными операциями C.
minimum, maximum = min(chunk), max(chunk) # Исходные конечные амплитуды сравниваются без повторной проверки math.isfinite.
if minimum < low_value: # Края идут слева направо, поэтому при равенстве оставляем уже найденный более ранний минимум.
low, low_value = begin + chunk.index(minimum), minimum # index возвращает первое вхождение точного минимума внутри неполного края.
if maximum > high_value: # При равенстве максимумов также сохраняем более раннее измерение.
high, high_value = begin + chunk.index(maximum), maximum # Индекс исходной амплитуды вычисляется смещением от начала короткого среза.
lows, highs = self.nodes[1], self.nodes[2] # Для конечного ряда нужны только индексы минимумов и максимумов; остальные три массива не читаются.
left, right = self.leaves + first_block, self.leaves + last_block # Переводим полуинтервал целых блоков в номера листьев компактного дерева.
while left < right: # Как и общий алгоритм, выбираем лишь логарифмическое число готовых сводок диапазона.
if left % 2: # Отдельный правый ребёнок слева входит в запрос целиком, а его сосед остаётся за границей.
candidate = lows[left] # Готовый индекс минимума узла ссылается непосредственно на исходный ряд.
value = values[candidate] # Читаем только амплитуду найденного в узле минимума.
if value < low_value or (value == low_value and candidate < low): # Узлы посещаются не строго слева направо, поэтому равные значения сравниваем и по исходному индексу.
low, low_value = candidate, value # Обновляем точное первое вхождение минимума без создания промежуточной сводки.
candidate = highs[left] # Второй готовый индекс узла соответствует точному исходному максимуму.
value = values[candidate] # Амплитуда максимума также читается один раз на выбранный узел.
if value > high_value or (value == high_value and candidate < high): # Равные максимумы должны сохранять прежний выбор самого раннего отсчёта.
high, high_value = candidate, value # Обновляем максимум и его амплитуду вместе для следующего сравнения.
left += 1 # Исключаем уже обработанный узел перед подъёмом обеих границ на следующий уровень.
if right % 2: # Левый сосед исключённой правой границы входит в диапазон полностью.
right -= 1 # Получаем номер последнего целиком включённого узла текущего уровня дерева.
candidate = lows[right] # Читаем индекс готового минимума правого узла без распаковки пяти полей сводки.
value = values[candidate] # Только выбранный минимум требует обращения к исходной амплитуде.
if value < low_value or (value == low_value and candidate < low): # Порядок обхода не влияет на первый по времени минимум при равенстве амплитуд.
low, low_value = candidate, value # Сохраняем лучший исходный минимум для объединённого запроса.
candidate = highs[right] # Индекс максимума берём из второго массива готовых экстремумов.
value = values[candidate] # Читаем точную амплитуду исходного максимума правого узла.
if value > high_value or (value == high_value and candidate < high): # При одинаковых амплитудах выбираем наименьший исходный индекс.
high, high_value = candidate, value # Объединяем максимум напрямую, исключая вызовы _load и _merge на каждом узле.
left //= 2 # Переходим от листьев или текущих узлов к покрывающим их родителям.
right //= 2 # Правая исключённая граница поднимается на тот же уровень дерева.
return start, low, high, stop - 1, 0 # Все точки конечны, поэтому внешние отсчёты и отсутствие пропусков не требуют отдельного вычисления.
def points(self, timestamps, left, right, width, *, extrema_only=False): # Обычная огибающая сохраняет четыре роли на столбец, а отдельный экранный режим оставляет только точные минимум и максимум.
count = min(len(timestamps), self.count) # Общий размер защищает от несовпадающих длин координат и значений.
start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Оба края видимого временного окна включаются полностью.
ranges = [] # Список содержит только диапазоны пикселей и по одному соседнему отсчёту для отсечения на краях.
if start: # Сосед слева сохраняет линию, пересекающую левую границу без отсчёта непосредственно внутри окна.
ranges.append((start - 1, start)) # Только один внешний отсчёт нужен для правильного геометрического отсечения.
if right <= left: # Совпадающие границы X могут содержать много отсчётов с одной меткой времени, но не допускают деления на ширину окна.
if stop > start: # Пустое окно не создаёт искусственный диапазон измерений.
ranges.append((start, stop)) # Экстремумы всех совпадающих меток времени ограничены четырьмя точками одного столбца.
else: # Любое окно делим по экранным координатам, чтобы неравномерные и повторяющиеся метки также соблюдали бюджет на столбец.
edge = start # Первый пиксель начинается с первого отсчёта внутри видимого окна.
for column in range(width): # Число итераций зависит только от ширины изображения в физических пикселях.
boundary = left + (right - left) * ((column + 1) / width) # Правая граница столбца задана в единицах исходной оси X.
end = stop if column == width - 1 else bisect_left(timestamps, boundary, edge, stop) # Последний столбец включает отсчёты точно на правой границе окна.
if end > edge: # Пустые пиксели не создают искусственных измерений.
ranges.append((edge, end)) # Запоминаем исходный диапазон для выбора первого, минимального, максимального и последнего отсчётов.
edge = end # Следующий столбец продолжает ряд без потерь и повторного включения границы.
if stop < count: # Сосед справа сохраняет линию, пересекающую правую границу окна.
ranges.append((stop, stop + 1)) # Внешний сосед добавляет не более одной конечной точки к пиксельному бюджету.
has_gaps = bool(self.nodes[4][1]) # Нулевой счётчик корня доказывает отсутствие пропусков во всём ряду и исключает лишние запросы между компактными экстремумами.
previous = None # Индекс предыдущей выбранной конечной точки нужен для проверки разрыва между столбцами.
for begin, end in ranges: # Обрабатываем только экранные диапазоны и двух возможных внешних соседей.
summary = self.between(begin, end) # Получаем точные экстремумы через индекс, читая лишь неполные края блоков.
roles = summary[1:3] if extrema_only else summary[:4] # Новый экранный режим уменьшает число вершин, не изменяя исходные экстремумы или прежний публичный результат.
if extrema_only and begin == start: # Первый видимый столбец сохраняет также начало линии, чтобы она правильно пересекала левый край окна.
roles += (summary[0],) # Дополнительная роль нужна только одному столбцу, а не каждому пикселю.
if extrema_only and end == stop: # Последний видимый столбец сохраняет конец линии для правильного пересечения правого края.
roles += (summary[3],) # Два внешних конца увеличивают общий бюджет только на две конечные точки.
selected = sorted({index for index in roles if index >= 0}) # Убираем совпадающие роли и восстанавливаем исходный временной порядок выбранных экстремумов.
if not selected: # Полностью нечисловой диапазон разрывает линию один раз независимо от числа NaN внутри пикселя.
yield None # Маркер разрыва не является измерением и обрабатывается обоими движками отрисовки.
previous = None # Следующая конечная точка начинает новый путь.
continue # В пустом пикселе нет конечных экстремумов для отрисовки.
for index in selected: # На один непустой пиксель приходится не более четырёх исходных измерений.
if has_gaps and previous is not None and previous + 1 < index and (summary[4] or previous < begin) and self.between(previous + 1, index)[4]: # Проверяем разрывы только при наличии NaN в записи; соседние индексы и доказанно конечные ряды не требуют дополнительных запросов дерева.
yield None # Разрыв сохраняет пропуски между выбранными экстремумами без повторного запроса того же диапазона дерева.
yield timestamps[index], self.values[index] # Возвращаем исходные координаты и значения без усреднения или изменения амплитуды.
previous = index # Запоминаем конец текущего пути для следующей выбранной точки.
+36 -8
View File
@@ -9,9 +9,34 @@ are separate objects, never channels in the acquisition or measurement model.
"""
from __future__ import annotations
import csv
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):
"""Normalized immutable samples, reusable across processing requests."""
def __new__(cls, points):
if isinstance(points, cls):
return points
result = super().__new__(cls, ((float(x), float(y)) for x, y in points))
result.ordered = all(result[i - 1][0] <= result[i][0] for i in range(1, len(result)))
return result
def between(self, left, right):
if self.ordered:
timestamps = PointTimes(self) # Создаём индексируемое представление X, сохраняя логарифмическую сложность поиска границ.
start = bisect_left(timestamps, left) # Начинаем с первой точки X >= left, включая все повторы левой границы.
stop = bisect_right(timestamps, right) # Заканчиваем перед первой точкой X > right, включая все повторы правой границы.
if start == 0 and stop == len(self):
return self
result = tuple.__new__(type(self), self[start:stop])
result.ordered = True
return result
return FrozenPoints((x, y) for x, y in self if left <= x <= right)
@dataclass(frozen=True)
@@ -36,7 +61,7 @@ class Series:
def __post_init__(self):
# A source may reuse mutable buffers immediately after publication.
object.__setattr__(self, "points", tuple((float(x), float(y)) for x, y in self.points))
object.__setattr__(self, "points", FrozenPoints(self.points))
@dataclass(frozen=True)
@@ -71,6 +96,7 @@ class Request:
output_count: int
degree: int
x_range: tuple | None
backend: str = "native" # Вариант входит в равенство запросов, поэтому две расчётные кривые могут сосуществовать.
@dataclass(frozen=True)
@@ -81,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:
@@ -90,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
@@ -103,16 +131,16 @@ def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
if snapshot.x_range is not None:
left, right = snapshot.x_range
selected = Series(selected.key, selected.label,
tuple((x, y) for x, y in selected.points if left <= x <= right),
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):
+10
View File
@@ -0,0 +1,10 @@
"""Точность подписей шкал при приближении до небольших долей исходного диапазона.""" # Форматирование подписей отделено от расчёта координат и не требует Qt.
import math # Логарифмы определяют число значащих цифр, необходимое для различения соседних делений.
def tick_precision(value, step, minimum=4): # Значение и шаг задаются в одинаковых единицах; минимум сохраняет обычный вид подписей при крупном масштабе.
if not math.isfinite(value) or not math.isfinite(step) or value == 0 or step <= 0: # Для нуля и непригодного шага порядок числа не определён, поэтому используем исходную точность.
return minimum # Без вычисления логарифма сохраняем безопасное форматирование специальных значений.
value_order = math.floor(math.log10(abs(value))) # Порядок абсолютной координаты учитывает значащие цифры перед десятичной точкой и большие смещения оси.
step_order = math.floor(math.log10(step)) # Порядок положительного шага показывает, до какого десятичного разряда нужно различать деления.
return min(16, max(minimum, value_order - step_order + 1)) # Ограничение 16 цифрами соответствует полезной точности double, а разность порядков сохраняет отдельную подпись для каждого деления.
+139 -50
View File
@@ -2,27 +2,40 @@
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtCore import QTimer, Signal # Таймер обновляет буфер, а общий сигнал сообщает владельцу об изменении любых настроек расшифровки.
from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QPushButton,
QCheckBox, QComboBox, QLabel, QLineEdit, QTableWidget, QTableWidgetItem,
QMessageBox, QApplication, QAbstractItemView, QFileDialog)
from ..can_transport import build_frame
from ..can_monitor import CanSession, PROTOCOLS, parse_filter, format_data, decode_protocol
from ..can_monitor import CanSession, PROTOCOLS, UMP_MAPS, parse_filter, parse_gas_filter, matches_filters, format_data, decode_protocol # Общие декодер, карты и независимые фильтры обеспечивают одинаковое поведение всех CAN-мониторов.
class MonitorControls(QWidget):
decoding_changed = Signal() # Владельцы обновляют онлайн и трассировку при смене формата, карты адресов или номера УМП.
def __init__(self, owner, directory, default="auto"):
super().__init__(owner)
self.owner = owner
self.directory = directory
self.session = CanSession()
self._dirty = False
layout = QHBoxLayout(self)
self._pending_frames = {} # Упорядоченные ключи объединяют повторы между тактами таймера и сохраняют порядок первого поступления новых кадров.
self._history_rows = {} # Ключ уникального кадра указывает на строку и последний показанный счётчик, исключая поиск по всей таблице.
self._history_total = 0 # Сумма показанных повторов обновляется по разнице счётчиков, без перебора накопленной истории.
self._history_needs_reset = False # Частичная ошибка записи требует однократной сверки таблицы с фактически сохранённой сессией.
controls_layout = QVBoxLayout(self) # Отдельная строка параметров УМП сохраняет компактную ширину основной панели монитора.
controls_layout.setContentsMargins(0, 0, 0, 0) # Панель встраивается в существующую вкладку без дополнительных внешних отступов.
layout = QHBoxLayout() # Основные кнопки остаются в прежнем горизонтальном порядке над параметрами УМП.
controls_layout.addLayout(layout) # Общие элементы занимают первую строку вертикального контейнера.
layout.setContentsMargins(0, 0, 0, 0)
self.protocol = QComboBox()
for key, label in PROTOCOLS:
self.protocol.addItem(label, key)
self.protocol.setCurrentIndex(self.protocol.findData(default))
self.protocol.setMinimumContentsLength(28)
self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
self.protocol.setToolTip("Выберите формат CAN. Для УМП / ProtoCAN GAS карта адресов и устройство выбираются отдельно.") # Подсказка объясняет разделение протокола и прикладного профиля.
legacy_ump = default in ("ump1_gas167", "ump2_gas167", "ump_gas") # Старые значения исходного профиля поддерживаются без возврата дублирующихся пунктов в список.
self.protocol.setCurrentIndex(self.protocol.findData("ump_protocan" if legacy_ump else default)) # Прежние профили УМП открывают единый формат с отдельно восстановленными параметрами.
layout.addWidget(QLabel("Протокол"))
layout.addWidget(self.protocol)
self.raw = QCheckBox("Без парса")
@@ -51,15 +64,52 @@ class MonitorControls(QWidget):
self.status = QLabel("Буфер: 0")
layout.addWidget(self.status)
layout.addStretch(1)
self.ump_options = QWidget() # Контейнер дополнительных параметров показывается только для формата УМП / ProtoCAN GAS.
ump_layout = QHBoxLayout(self.ump_options) # Карта и номер УМП расположены рядом на второй строке панели.
ump_layout.setContentsMargins(0, 0, 0, 0) # Вложенная строка выравнивается с основными элементами монитора.
ump_layout.addWidget(QLabel("Карта адресов")) # Подпись отличает нумерацию регистров от формата CAN-кадра.
self.ump_map = QComboBox() # Выбор карты определяет словарь параметров при неизменном формате ProtoCAN.
for key, label in UMP_MAPS: # Оба монитора используют единый каталог действующей и планируемой карт.
self.ump_map.addItem(label, key) # Пользователь видит статус карты, а декодер получает устойчивый ключ профиля.
self.ump_map.setToolTip("167/v1: слова 0…127 и 0x5000…0x507F. Новые десятичные ID 5000…5127 и 6000…6127 назначены, но ещё не перенесены в прошивку; устройство определяется по ID.") # Подсказка указывает диапазоны обеих карт и состояние планируемой адресации.
self.ump_map.setCurrentIndex(self.ump_map.findData("assigned" if default == "ump_gas" else "gas167")) # Прежний профиль новых ID восстанавливает сквозную карту, остальные начинают с текущей.
ump_layout.addWidget(self.ump_map) # Карта выбирается независимо от списка форматов CAN.
self.ump_node_label = QLabel("Устройство") # Номер УМП относится к устройству, а не к отдельному протоколу.
ump_layout.addWidget(self.ump_node_label) # Подпись находится перед селектором номера УМП.
self.ump_node = QComboBox() # Текущая локальная карта требует ручного назначения УМП 1 или УМП 2.
self.ump_node.addItem("УМП 1", 1) # Число 1 обозначает первый УМП и передаётся декодеру как номер устройства.
self.ump_node.addItem("УМП 2", 2) # Число 2 обозначает второй УМП; карта регистров остаётся общей для обоих приборов.
self.ump_node.setCurrentIndex(self.ump_node.findData(2 if default == "ump2_gas167" else 1)) # Старый профиль второго УМП сохраняет выбранный прибор, остальные используют первый.
self.ump_node.setToolTip("Номер УМП для карты 167/v1 задаётся вручную. В новой карте устройство определяется по GAS ID.") # Подсказка объясняет, почему ручной выбор скрывается для сквозных ID.
ump_layout.addWidget(self.ump_node) # Устройство занимает собственное поле после карты адресов.
ump_layout.addStretch(1) # Свободное место справа удерживает дополнительные поля у левого края панели.
controls_layout.addWidget(self.ump_options) # Параметры УМП добавляются под основной строкой без расширения списка протоколов.
self._update_ump_options() # Исходный формат определяет видимость дополнительной строки до первого показа вкладки.
self.timer = QTimer(self)
self.timer.setInterval(250)
self.timer.timeout.connect(self.refresh)
self.timer.start()
QApplication.instance().aboutToQuit.connect(self.session.close)
self.destroyed.connect(lambda: self.session.close())
self.protocol.currentIndexChanged.connect(lambda *_: self.refilter())
self.protocol.currentIndexChanged.connect(self._change_protocol) # Смена формата обновляет параметры УМП и все представления принятых кадров.
self.ump_map.currentIndexChanged.connect(self._change_protocol) # Смена карты перерасшифровывает буфер и обновляет необходимость ручного номера устройства.
self.ump_node.currentIndexChanged.connect(self._change_protocol) # Смена устройства применяется к уже принятым кадрам текущей локальной карты.
self.raw.toggled.connect(lambda *_: self.refilter())
def _update_ump_options(self): # Видимость дополнительных полей зависит от формата и способности карты определить устройство по адресу.
self.ump_options.setVisible(self.protocol.currentData() == "ump_protocan") # Остальные форматы обходятся без настроек карты УМП.
manual_node = self.ump_map.currentData() == "gas167" # Только действующая локальная карта требует вручную выбранного номера УМП.
self.ump_node_label.setVisible(manual_node) # Подпись устройства скрывается вместе с ненужным ручным селектором новой карты.
self.ump_node.setVisible(manual_node) # Сквозные ID сами задают УМП, поэтому ручной номер не должен противоречить адресу кадра.
def _change_protocol(self, *_args): # Сигналы трёх селекторов используют общий путь обновления, не зависящий от индекса изменённого поля.
self._update_ump_options() # Сначала интерфейс показывает только применимые к выбранному формату параметры.
self.refilter() # Таблица уникальных кадров сразу получает расшифровку с новой картой и устройством.
self.decoding_changed.emit() # Владелец обновляет онлайн, трассировку и сведения о ранее принятых кадрах.
def decode(self, frame): # Все таблицы монитора разбирают кадры с одним набором выбранных параметров.
return decode_protocol(frame, self.protocol.currentData(), ump_map=self.ump_map.currentData(), ump_node=self.ump_node.currentData()) # Формат, карта и номер УМП передаются независимо, исключая расхождение онлайн и буфера.
def format_data(self, data):
return format_data(data, self.data_format.isChecked())
@@ -73,13 +123,28 @@ class MonitorControls(QWidget):
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
row.addWidget(QLabel("Фильтр ID (hex)"))
self.filter_enabled = QCheckBox("Фильтр CAN ID (hex)") # Первый флажок управляет существующим отбором по идентификатору кадра.
self.filter_enabled.setChecked(True) # Сохраняем прежнее поведение: ввод CAN ID сразу фильтрует нижнюю таблицу.
row.addWidget(self.filter_enabled) # Флажок занимает место прежней подписи фильтра.
self.filter = QLineEdit()
self.filter.setPlaceholderText("701 или 0x701, 201")
self.filter.setToolTip("Фильтр только нижней таблицы; сверху отображаются все ID")
self.filter.setToolTip("CAN ID в HEX. Оба включённых фильтра работают по И; фильтруется только нижняя таблица, сверху остаются все кадры.") # Поясняем совместный отбор и сохраняем независимость основных таблиц.
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(self.refilter)
row.addWidget(self.filter)
self.gas_filter_enabled = QCheckBox("Фильтр GAS (dec / 0xHEX)") # Второй флажок позволяет включить адреса GAS отдельно или совместно с CAN ID.
row.addWidget(self.gas_filter_enabled) # Размещаем новый критерий в той же строке рядом с существующим фильтром.
self.gas_filter = QLineEdit() # Поле содержит список адресов слов GAS, независимо от списка идентификаторов кадров.
self.gas_filter.setPlaceholderText("5000, 5127 или 0x5000") # Примеры различают десятичные назначенные ID и шестнадцатеричное локальное окно.
self.gas_filter.setToolTip("GAS ID: DEC либо HEX с 0x, через запятую. Учитываются все слова кадра. При включённых обоих фильтрах должны совпасть и CAN ID, и GAS ID.") # Объясняем формат и поиск адреса внутри многословной посылки.
self.gas_filter.setClearButtonEnabled(True) # Очистка временно убирает GAS-условие без изменения флажка CAN ID.
self.gas_filter.setEnabled(False) # Новый фильтр включается пользователем явно и не меняет прежний начальный экран.
self.gas_filter.textChanged.connect(self.refilter) # Изменение списка сразу пересчитывает строки и счётчики сохранённого буфера.
self.filter_enabled.toggled.connect(self.filter.setEnabled) # Выключенный CAN-критерий сохраняет введённый текст для повторного включения.
self.filter_enabled.toggled.connect(self.refilter) # Снятие флажка сразу убирает ограничение CAN ID.
self.gas_filter_enabled.toggled.connect(self.gas_filter.setEnabled) # Состояние поля показывает, применяется ли GAS-условие.
self.gas_filter_enabled.toggled.connect(self.refilter) # Каждый флажок независимо пересчитывает пересечение двух критериев.
row.addWidget(self.gas_filter) # Поле GAS расположено перед общим счётчиком совпавших кадров.
self.count = QLabel("Кадров: 0")
row.addWidget(self.count)
layout.addLayout(row)
@@ -100,8 +165,10 @@ class MonitorControls(QWidget):
def ingest(self, identifier, data, extended, rtr=False, source="CAN", timestamp=None):
try:
self.session.append(identifier, data, extended, rtr, source, timestamp)
key = self.session.append(identifier, data, extended, rtr, source, timestamp) # Сессия возвращает точную идентичность кадра с учётом канала, STD/EXT, RTR и данных.
self._pending_frames[key] = None # Один ключ объединяет любое число повторов до следующего обновления экрана.
except OSError as exc:
self._history_needs_reset = True # Ошибка могла возникнуть после изменения буфера, поэтому следующий такт заново сверит все строки.
self.status.setText("Ошибка записи: " + str(exc))
self.session.stop()
self.record.setText("● Запись")
@@ -153,46 +220,68 @@ class MonitorControls(QWidget):
if not self.owner._paused and self._dirty:
self._dirty = False
self.status.setText("Буфер: %d" % sum(self.session.counts.values()))
self.refilter()
self._update_history(self._history_needs_reset) # Обычный такт обрабатывает лишь новые данные; полная сверка нужна только после ошибки записи.
def refilter(self):
if not hasattr(self, "filter"):
return
try:
ids = parse_filter(self.filter.text())
except ValueError as exc:
self.count.setText(str(exc))
ids = set()
else:
count = sum(value for key, value in self.session.counts.items()
if ids is None or key in ids)
unique_count = sum(1 for values, _ in self.session.unique_frames.values()
if ids is None or int(values[3], 16) in ids)
self.count.setText("Кадров: %d; уникальных: %d" % (count, unique_count))
self.history.horizontalHeaderItem(7).setText(
"Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)")
self.history.setColumnHidden(8, self.raw.isChecked())
self.history.setVisible(ids is not None)
if ids is None:
self.history.setRowCount(0)
return
frames = [(values, count) for values, count in self.session.unique_frames.values()
if int(values[3], 16) in ids]
self.history.setRowCount(len(frames))
for row, (values, count) in enumerate(frames):
data = bytes.fromhex(values[6])
summary = ""
if not self.raw.isChecked():
frame = build_frame(int(values[3], 16), data,
ide=values[2] == "EXT", rtr=bool(values[4]),
to_can=values[1] == "TX")
summary = decode_protocol(frame, self.protocol.currentData()).summary
display = values[:6] + (count, self.format_data(data), summary)
for column, value in enumerate(display):
item = self.history.item(row, column)
if item is None:
self.history.setItem(row, column, QTableWidgetItem(str(value)))
elif item.text() != str(value):
item.setText(str(value))
if column == 8:
self.history.item(row, column).setToolTip(summary)
def refilter(self): # Изменение фильтра, протокола или формата требует однократного пересчёта существующих строк.
self._update_history(True) # Полная перестройка применяется только по явному изменению настроек либо сбросу буфера.
def _update_history(self, rebuild): # Таймер и настройки используют один путь отбора, но разный объём входных записей.
if not hasattr(self, "filter"): # При сборке панели сигналы могут прийти до создания нижней таблицы.
return # Отложим отображение до завершения построения элементов интерфейса.
error = None # Ошибка активного фильтра заменяет счётчики понятным сообщением и очищает выборку.
try: # Разбор коротких условий выполняется одинаково для полного и добавочного обновления.
ids = parse_filter(self.filter.text()) if self.filter_enabled.isChecked() else None # Текст выключенного CAN-фильтра не влияет на выборку и не вызывает ошибок ввода.
gas_ids = parse_gas_filter(self.gas_filter.text()) if self.gas_filter_enabled.isChecked() else None # Независимо разбираем только включённое условие адресов GAS.
except ValueError as exc: # Неверный ввод не должен оставлять на экране строки от прежнего корректного фильтра.
error = str(exc) # Сохраняем объяснение для итоговой подписи после очистки строк.
ids, gas_ids = set(), None # Пустое множество CAN ID даёт пустую видимую выборку.
filtered = ids is not None or gas_ids is not None # Нижняя таблица нужна лишь при наличии хотя бы одного непустого условия.
self.history.horizontalHeaderItem(7).setText("Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)") # Заголовок соответствует выбранному представлению байтов.
self.history.setColumnHidden(8, self.raw.isChecked()) # Сырой режим скрывает расшифровку независимо от способа обновления.
self.history.setVisible(filtered) # Без ограничений сохраняем компактный вид основной таблицы.
updates = self.history.updatesEnabled() # Запоминаем исходный режим отрисовки, чтобы не включать её вопреки владельцу.
self.history.setUpdatesEnabled(False) # Пачка изменений не должна вызывать промежуточную перерисовку и перерасчёт геометрии.
blocked = self.history.blockSignals(True) # Программное заполнение не должно вызывать обработчики отдельных изменённых ячеек.
try: # Восстановление состояния Qt гарантируется также при ошибке декодирования.
if rebuild or not filtered or error is not None: # Смена условий и недопустимый ввод отменяют прежнее соответствие ключей строкам.
self.history.setRowCount(0) # Полная очистка выполняется по настройке, а не каждые 250 мс при приёме.
self._history_rows.clear() # Индексы строк больше не относятся к новой выборке.
self._history_total = 0 # Сумма повторов пересчитывается для нового набора подходящих кадров.
if filtered and error is None: # Скрытая таблица и некорректные условия не требуют фильтрации или декодирования истории.
keys = self.session.unique_frames if rebuild else self._pending_frames # Только явная перестройка перебирает всю сессию; таймер берёт изменённые ключи.
for key in keys: # Словари сохраняют порядок первого появления уникальных кадров, даже если между ними пришли повторы.
values, count = self.session.unique_frames[key] # Получаем последние время и счётчик с учётом всех повторов в текущей пачке.
previous = self._history_rows.get(key) # Уже показанный кадр прошёл неизменные фильтры и не нуждается в повторной расшифровке.
if previous is not None: # У повторов меняются только время последнего приёма и число физических кадров.
row, old_count = previous # Сохраняем прежнюю сумму, чтобы добавить только новые приёмы.
display = ((0, values[0]), (6, count)) # Колонки 0 и 6 содержат время и счётчик, остальные данные идентичны по ключу.
else: # Новый ключ необходимо проверить по обоим активным условиям.
data = bytes.fromhex(values[6]) # Данные нужны для GAS-отбора и однократной расшифровки новой строки.
if not matches_filters(int(values[3], 16), data, values[2] == "EXT", bool(values[4]), can_ids=ids, gas_ids=gas_ids): # Проверяем пересечение CAN/GAS с учётом формата и RTR.
continue # Неподходящий кадр остаётся в полной сессии для сохранения и последующей смены фильтров.
row, old_count = self.history.rowCount(), 0 # Новая уникальная посылка добавляется в конец с её полным счётчиком с начала сессии.
self.history.insertRow(row) # Существующие строки и выделение не пересоздаются при поступлении новой посылки.
summary = "" # В режиме без парса не вызываем декодер даже при первом отображении кадра.
if not self.raw.isChecked(): # Расшифровка вычисляется только для новой строки либо при явной перестройке настроек.
frame = build_frame(int(values[3], 16), data, ide=values[2] == "EXT", rtr=bool(values[4]), to_can=values[1] == "TX") # Восстанавливаем исходную метаинформацию сохранённой посылки.
summary = self.decode(frame).summary # Используем выбранные протокол, карту адресов и номер устройства.
display = enumerate(values[:6] + (count, self.format_data(data), summary)) # Новая строка заполняется целиком в порядке заголовков таблицы.
for column, value in display: # У повторов этот цикл касается двух ячеек вместо всей накопленной таблицы.
item = self.history.item(row, column) # Повторно используем существующий объект ячейки для сохранения выделения.
if item is None: # Ячейки создаются только при добавлении новой уникальной посылки.
item = QTableWidgetItem() # Пустой объект получает текст общим путём ниже.
self.history.setItem(row, column, item) # Таблица принимает владение созданной ячейкой.
if item.text() != str(value): # Не уведомляем модель Qt о фактически неизменившемся значении.
item.setText(str(value)) # Преобразование сохраняет прежнее отображение чисел и меток времени.
if column == 8: # Последний столбец содержит длинную расшифровку, которую удобно читать в подсказке.
item.setToolTip(str(value)) # Подсказка обновляется вместе с новой расшифровкой при смене настроек.
self._history_rows[key] = (row, count) # Запоминаем актуальные индекс и показанный счётчик для следующей пачки повторов.
self._history_total += count - old_count # Добавляем только новые физические приёмы без суммирования всей истории.
self._pending_frames.clear() # Все поступившие ключи учтены либо скрыты неизменными фильтрами.
self._history_needs_reset = False # Сверка после ошибки успешно завершена и больше не требует полного обхода.
total = self._history_total if filtered else sum(self.session.counts.values()) # Без фильтра подпись относится ко всей сессии, хотя сама таблица скрыта.
unique = len(self._history_rows) if filtered else len(self.session.unique_frames) # Число уникальных кадров берём из готового индекса без повторного отбора.
self.count.setText(error if error is not None else "Кадров: %d; уникальных: %d" % (total, unique)) # При ошибке оставляем причину, иначе показываем согласованные счётчики выборки.
finally: # Любой выход должен вернуть исходное поведение сигналов и перерисовки Qt.
self.history.blockSignals(blocked) # Восстанавливаем состояние, установленное владельцем до обновления.
self.history.setUpdatesEnabled(updates) # Готовая пачка отображается за одно обновление виджета.
File diff suppressed because one or more lines are too long
@@ -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()) # нГн и нФ переводятся в СИ; Ом уже соответствует внутренним формулам.
+32 -8
View File
@@ -2,6 +2,8 @@
from collections import deque # Кольцевая история ограничивает память при непрерывном обмене.
from html import escape # Текст устройства не должен становиться HTML-разметкой журнала.
from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8.
import csv # Стандартный CSV корректно экранирует кавычки, разделители и пустые строки журнала.
import json # JSON сохраняет строки с кириллицей без потери символов и дополнительного разбора протоколов.
import re # Метки направления извлекаются только из начала строки после необязательного времени.
try: # Поддерживаем актуальный Qt и существующие установки на Python для Windows 7.
from PySide6.QtCore import QTimer, Signal # Таймер объединяет обновления заполненного журнала.
@@ -48,12 +50,12 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк.
self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории.
filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли.
self.show_tx, self.show_rx = QCheckBox("TX"), QCheckBox("RX") # Служебные сообщения не относятся ни к одному направлению.
self.show_tx, self.show_rx = QCheckBox("TX", self.panel), QCheckBox("RX", self.panel) # Родитель назначается до setVisible, чтобы фильтры направлений не появлялись отдельными окнами при сборке консоли.
for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории.
control.setChecked(True) # Первое открытие показывает весь обмен.
control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы.
filters.addWidget(control) # Направления расположены рядом с текстовым фильтром.
self.display = QComboBox() # Формат байтов относится только к сообщениям с явно переданным payload.
self.display = QComboBox(self.panel) # Родитель исключает отдельное окно при setVisible(payloads); формат относится только к сообщениям с явно переданными байтами.
self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются.
self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки.
self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload.
@@ -103,7 +105,6 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления.
self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок.
self.records.clear() # Сохраняется только актуальный результат форматирования потребителя.
if not self.pause.isChecked(): super().clear() # При паузе прежний экран сохраняется до возобновления.
if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись.
self.refresh() # Поиск и фильтры применяются к новому снимку целиком.
@@ -124,6 +125,7 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
def _append_record(self, record): # Добавляем одну подготовленную запись, учитывая формат и положение прокрутки.
text, html, _role, payload = record # Байты присутствуют только при явной передаче payload приложением.
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Запоминаем позицию до изменения размера документа.
horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Автопрокрутка по вертикали не должна сбрасывать просмотр длинной строки по горизонтали.
mode = self.display.currentIndex() # Нулевой режим сохраняет текст и цвета, заданные потребителем.
if payload is not None and mode: # Формат меняет только подписанную приложением полезную нагрузку.
hexadecimal = payload.hex(" ").upper() # HEX показывает каждый исходный байт двумя цифрами.
@@ -132,25 +134,47 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
elif html: super().appendHtml(html) # Сохраняем безопасное оформление или прежний фрагмент приложения.
else: super().appendPlainText(text) # Обычный текст не интерпретируется как HTML.
bar.setValue(bar.maximum() if self.follow.isChecked() else position) # Отключённая автопрокрутка сохраняет место чтения.
horizontal.setValue(offset) # Дописывание строки сохраняет выбранный горизонтальный участок журнала.
def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст.
self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение.
if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия.
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Сохраняем место чтения до очистки документа.
horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Фильтрация и пакетное обновление не должны возвращать длинные строки к левому краю.
super().clear() # Очищаем только документ, сохраняя ограниченную историю.
for record in self.records: # Показываем оставшиеся записи в исходном порядке.
if self._visible(record): self._append_record(record) # Скрытые строки остаются доступными для следующего выбора фильтра.
bar.setValue(bar.maximum() if self.follow.isChecked() else min(position, bar.maximum())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном.
horizontal.setValue(offset) # Qt ограничивает прежнее смещение текущей шириной документа после перестроения.
self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи.
def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра.
QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения.
def save_to(self, path): # Отдельный метод позволяет проверить экспорт без системного диалога.
Path(path).write_text(self.toPlainText(), encoding="utf-8") # UTF-8 сохраняет русские сообщения и расшифровки протоколов.
def save_to(self, path, file_format=None): # Явный формат используется диалогом, а прямой вызов определяет формат по расширению файла.
destination = Path(path) # Путь может поступить строкой от Qt или объектом Path от вызывающего кода.
file_format = (file_format or destination.suffix.lstrip(".") or "txt").lower() # Имена расширений нечувствительны к регистру; без расширения сохраняется прежний TXT.
if file_format not in ("csv", "json", "txt"): # Не допускаем сохранения текста под видом неподдерживаемого формата.
raise ValueError("Выберите формат CSV, JSON или TXT") # Ошибка доступна как прямому вызывающему коду, так и диалогу.
text = self.toPlainText() # Единый снимок учитывает фильтры, формат байтов и замороженный на паузе экран.
rows = [{"line": index, "text": line} for index, line in enumerate(text.split("\n"), 1)] if text else [] # Нумерация с единицы сохраняет порядок и пустые строки расшифровки.
if file_format == "csv": # Табличное представление содержит номер строки и её полный текст.
with destination.open("w", encoding="utf-8-sig", newline="") as stream: # BOM помогает Excel распознать кириллицу; newline исключает лишние пустые строки в Windows.
writer = csv.DictWriter(stream, fieldnames=("line", "text")) # Стабильные имена столбцов позволяют обрабатывать журналы скриптами.
writer.writeheader() # Даже пустой журнал получает корректную схему CSV.
writer.writerows(rows) # Стандартный писатель экранирует кавычки и запятые без изменения содержимого.
elif file_format == "json": # Массив объектов сохраняет ту же схему строк, что CSV.
destination.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Читаемый UTF-8 сохраняет кириллицу и валидный пустой массив.
else: # TXT остаётся точной текстовой копией показанного журнала.
destination.write_text(text, encoding="utf-8") # Прежний контракт сохранения TXT не добавляет заголовки или номера строк.
def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала.
path, _filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console.txt", "Текст UTF-8 (*.txt);;Все файлы (*)") # Диалог не выполняет автоматической записи при отмене.
filters = "CSV UTF-8 (*.csv);;JSON UTF-8 (*.json);;Текст UTF-8 (*.txt)" # Все консоли предлагают одинаковые три формата; CSV выбран первым.
path, selected_filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console", filters) # Имя без расширения позволяет выбрать любой формат без ручной замены суффикса.
if path: # Записываем только явно выбранный файл.
try: self.save_to(path) # Обрабатываем отказ файловой системы без прерывания приёма данных.
except OSError as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
file_format = Path(path).suffix.lstrip(".").lower() # Явно введённое поддерживаемое расширение имеет приоритет над фильтром диалога.
if file_format not in ("csv", "json", "txt"): # Если расширение отсутствует или неизвестно, применяем выбранный формат.
file_format = "json" if "*.json" in selected_filter else "txt" if "*.txt" in selected_filter else "csv" # При отсутствии фильтра Qt используется первый предложенный формат CSV.
path += "." + file_format # Итоговое расширение всегда соответствует фактически записанному формату.
try: self.save_to(path, file_format) # Обрабатываем отказ файловой системы без прерывания приёма данных.
except (OSError, ValueError) as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
+3 -3
View File
@@ -14,7 +14,7 @@ except ImportError:
DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — '
'перемещение по X; Ctrl + ЛКМ или Shift + ЛКМ — перемещение по Y. '
'перемещение по X; Ctrl + ЛКМ — перемещение по Y; Shift + ЛКМ — по Y со стороны начала жеста. ' # Для двух шкал Shift выбирает сторону в точке нажатия и сохраняет её до отпускания кнопки.
'Щелчок мимо маркеров — одиночный курсор.')
@@ -34,7 +34,7 @@ def cursor_hit(plot, position, value, event, radius=8):
class PlotDragInteraction:
"""Capture a hit marker or pan one axis, chosen at the start of the drag."""
def _capture_y_pan(self):
def _capture_y_pan(self, event=None): # Необязательное событие нажатия позволяет владельцу двух шкал выбрать одну Y; клавиатура передаёт только исходную область.
return self.viewport
def _apply_y_pan(self, fraction, initial):
@@ -58,7 +58,7 @@ class PlotDragInteraction:
self.setFocus(Qt.MouseFocusReason)
self._press = QPointF(pos)
self._press_viewport = self.viewport
self._press_y_pan = self._capture_y_pan()
self._press_y_pan = self._capture_y_pan(event) # Фиксируем выбранную шкалу и её исходный диапазон при нажатии, чтобы пересечение центра не меняло цель перетаскивания.
self._press_markers = self.markers
self._press_bounds = self.visible_bounds
self._pan_vertical = vertical_pan_requested(event)
+36 -19
View File
@@ -8,18 +8,28 @@ except ImportError:
from PySide2.QtCore import QPointF, Qt
from PySide2.QtGui import QColor
from protocan.plot import Bounds, Viewport
from set_devices.plot_ticks import tick_precision # Обе амплитудные шкалы должны различать соседние деления даже при сильном приближении.
def amplitude_axis_margin(widget, bounds): # Отступ правой шкалы зависит от ширины её уточнённых подписей в текущем шрифте виджета.
step = (bounds.top - bounds.bottom) / max(100, widget.height() / 50) # Консервативная плотность делений даёт запас точности без обращения к геометрии, которая сама вычисляет этот отступ.
labels = [f'{value:.{tick_precision(value, step)}g}' for value in (bounds.bottom, bounds.top)] # Оцениваем самые длинные крайние подписи в том же формате, что используется при отрисовке шкалы.
return max(72, 14 + max(widget.fontMetrics().horizontalAdvance(label) for label in labels)) # Прежний минимум 72 пикселя и запас 14 пикселей сохраняют обычный вид и предотвращают обрезание длинных чисел.
def small_amplitude(values, threshold=30):
extent = getattr(values, "finite_extent", None)
if extent is not None:
return max(abs(extent[0]), abs(extent[1])) <= threshold
magnitude = max((abs(v) for v in values if math.isfinite(v)), default=None)
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)
@@ -29,7 +39,8 @@ def split_amplitudes(series, threshold=30):
class DualAxisPlot:
"""Mixin: left axis for large channels, right for small channels.
Shift-wheel selects the axis by the pointer's half of the analog pane.
Ctrl + колесо выбирает шкалу по половине аналоговой области под указателем.
Shift + ЛКМ перемещает одну Y, выбранную стороной графика при нажатии.
The host supplies series_bounds/project_series rendering hooks.
"""
def __init__(self, *args, **kwargs):
@@ -40,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()
@@ -49,10 +60,11 @@ 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(
v for v in series.values if math.isfinite(v))
getattr(series.values, "finite_extent", None) or
(v for v in series.values if math.isfinite(v)))
ranges = []
for values in groups:
low, high = (min(values), max(values)) if values else (0, 1)
@@ -62,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):
@@ -111,23 +123,28 @@ class DualAxisPlot:
def plot_rect(self):
rect = super().plot_rect()
if self.right_axis_keys:
rect.setWidth(max(1, rect.width() - 72))
rect.setWidth(max(1, rect.width() - self.right_axis_margin())) # Уточнённые подписи правой Y получают достаточно места без выхода за границы виджета.
return rect
def right_axis_margin(self):
return 72 if self.right_axis_keys else 0
return amplitude_axis_margin(self, self.right_visible_bounds) if self.right_axis_keys else 0 # Используем фактический правый диапазон и текущий шрифт; при отсутствии правой шкалы отступ не нужен.
def reset_viewport(self):
self.right_viewport = Viewport()
super().reset_viewport()
def _capture_y_pan(self):
return super()._capture_y_pan(), self.right_viewport
def _capture_y_pan(self, event=None): # Снимок содержит области выбранных шкал; None исключает другую шкалу из всего текущего жеста.
left, right = super()._capture_y_pan(event), self.right_viewport # Сохраняем начальные области до движения, чтобы смещение считалось от точки нажатия без накопления ошибок.
if event is not None and event.modifiers() & Qt.ShiftModifier and self.right_axis_keys and self.has_left_axis(): # Выбор стороны нужен только для Shift с двумя отдельными Y; Ctrl, стрелки и единственная Y сохраняют общий перенос.
position = event.position() if hasattr(event, 'position') else event.localPos() # Получаем локальную точку нажатия одинаково для Qt 6 и Qt 5.
return (None, right) if position.x() >= self.value_rect().center().x() else (left, None) # Центр аналоговой области разделяет стороны; на самой границе выбираем правую Y, как при Ctrl с колесом.
return left, right # Общие команды перемещения получают обе области, а график с одной Y использует свою основную область.
def _apply_y_pan(self, fraction, initial):
left, right = initial
super()._apply_y_pan(fraction, left)
if self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis():
if left is not None: # Отсутствующая левая область означает, что Shift выбрал только правую шкалу при нажатии.
super()._apply_y_pan(fraction, left) # Общий обработчик перемещает левую Y и учитывает её фиксацию без изменения временной оси.
if right is not None and self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): # Правая Y сдвигается лишь при включении в снимок жеста, наличии двух шкал и снятой общей фиксации Y.
self.right_viewport = right.transform(self.core, pan_y=fraction)
def reset_interaction(self):
@@ -135,7 +152,7 @@ class DualAxisPlot:
super().reset_interaction()
def wheelEvent(self, event):
if not event.modifiers() & Qt.ShiftModifier:
if not event.modifiers() & Qt.ControlModifier: # Ctrl включает масштаб Y выбранной стороны; колесо без Ctrl, включая Shift, передаётся общей навигации по X.
return super().wheelEvent(event)
pos = event.position() if hasattr(event, 'position') else event.posF()
rect = self.value_rect()
@@ -173,7 +190,7 @@ def paint_right_axis(painter, core, rect, bounds):
for index in range(count):
value = (first + index) * step
y = rect.top() + bounds.fraction(core, value, True) * rect.height()
painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}')
painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.{tick_precision(value, step)}g}') # Сохраняем четыре значащие цифры при обычном масштабе и добавляем разряды по шагу правой шкалы при приближении.
painter.restore()
@@ -185,7 +202,7 @@ class SingleAmplitudeAxis:
def plot_rect(self):
rect = super().plot_rect()
if not self.has_left_axis():
rect.setWidth(max(1, rect.width() - 72))
rect.setWidth(max(1, rect.width() - amplitude_axis_margin(self, self.visible_bounds))) # Единственная правая шкала тренда или спектра получает тот же запас для длинных подписей при приближении.
return rect
def paint_axes(self, painter):
@@ -10,7 +10,8 @@ except ImportError:
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QScrollArea
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо и Ctrl + +/− — масштаб Y. '
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо — масштаб Y со стороны указателя; ' # Поясняем выбор левой или правой амплитудной шкалы по положению указателя при удерживаемом Ctrl.
'Ctrl + +/− — масштаб Y. ' # Клавиатурный масштаб Y сохраняет прежнее поведение без выбора стороны указателем.
'ЛКМ — перемещение по X; Ctrl + ЛКМ — по Y; '
'←/→ — перемещение по X; ↑/↓ — по Y; Home/End — начало/конец.')
+21 -9
View File
@@ -21,7 +21,7 @@ except ImportError:
QVBoxLayout, QWidget,
)
from set_devices.signal_reconstruction import 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()
@@ -88,8 +93,8 @@ class SignalProcessingPanel(QWidget):
row.addWidget(self.degree)
row.addWidget(QLabel("Точек результата"))
self.count = QSpinBox()
self.count.setRange(2, 10000)
self.count.setValue(1000)
self.count.setRange(2, MAX_OUTPUT_POINTS)
self.count.setValue(10000)
row.addWidget(self.count)
self.apply_button = QPushButton("Рассчитать")
self.apply_button.clicked.connect(self.calculate)
@@ -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()
+35 -7
View File
@@ -1,24 +1,27 @@
"""Reusable background workers for dense plots and viewport measurements."""
from collections import deque # Кольцевые очереди живого графика требуют единого снимка между проходами амплитудной статистики и частоты.
try:
from PySide6.QtCore import QObject, QRunnable, Signal
except ImportError:
from PySide2.QtCore import QObject, QRunnable, Signal
from set_devices.plot_decimation import pixel_envelope, viewport_samples
from set_devices.plot_decimation import pixel_envelope, render_envelope, viewport_samples # Рабочее задание поддерживает точный прежний формат и компактную экранную огибающую для аналогового графика.
from set_devices.oscilloscope import measurements
from set_devices.background_work import WorkBudget # Даже фоновый Python-расчёт должен регулярно отдавать GIL многочисленным обработчикам интерфейса Qt.
class EnvelopeSignals(QObject):
ready = Signal(str, object, object)
class EnvelopeJob(QRunnable):
def __init__(self, key, geometry, timestamps, values, left, right, width, signals):
def __init__(self, key, geometry, timestamps, values, left, right, width, signals, compact=False): # Необязательный режим оставляет прежний контракт внешних пользователей, а аналоговый график включает два экстремума на столбец.
super().__init__()
self.args = (timestamps, values, left, right, width)
self.key, self.geometry, self.signals = key, geometry, signals
self.select = render_envelope if compact else pixel_envelope # Выбор алгоритма выполняется один раз при создании задания, не внутри многомиллионного обхода.
def run(self):
try:
points = tuple(pixel_envelope(*self.args))
points = tuple(self.select(*self.args)) # Фоновый результат сохраняет точные пики и разрывы в согласованном с рисователем экранном бюджете.
except Exception:
points = None
try:
@@ -31,18 +34,43 @@ class NavigationSignals(QObject):
ready = Signal(int, object, object)
class _NavigationSamples: # Повторяемая выборка окна позволяет измерять исходные ряды без копии миллионов пар координат.
def __init__(self, times, values, bounds, check_cancelled): # Храним источники задания; вызывающий код создаёт снимок изменяемых очередей перед численными проходами.
self.times, self.values, self.bounds, self.check_cancelled = times, values, bounds, check_cancelled # Ссылки сохраняют исходную точность и порядок без экранного сокращения.
self.count = 0 # Число отсчётов определяется при первом полном проходе и включает нечисловые амплитуды, как прежний список.
def __iter__(self): # Статистики и частота независимо обходят то же окно, не удерживая промежуточные пары в памяти.
count = 0 # Каждый проход считает только точки внутри включительных временных границ.
for index, (x, y) in enumerate(viewport_samples(self.times, self.values, self.bounds.left, self.bounds.right)): # Индексируемый источник ищет окно двоичным поиском; потоковый сохраняет прежний порядок.
if index % 1024 == 0: # Проверяем отмену через 1024 просмотренных отсчёта, даже когда неупорядоченный ряд лежит вне окна.
self.check_cancelled() # Не продолжаем чтение старого диапазона после нового жеста навигации.
if self.bounds.left <= x <= self.bounds.right: # Включаем обе границы и все дубликаты временных меток, как исходная реализация.
count += 1 # Счётчик соответствует прежнему len(points), независимо от конечности амплитуды.
yield x, y # Пара существует только на время текущего численного прохода и не копируется в большой список.
self.count = count # Публикуем количество только после полного прохода, чтобы отменённая выборка не выдавала частичный результат.
class NavigationJob(QRunnable):
def __init__(self, revision, series, bounds, emitter):
def __init__(self, revision, series, bounds, emitter, cancelled=None): # Необязательное событие отмены сохраняет совместимость общих графиков и останавливает устаревшие измерения.
super().__init__()
self.revision, self.series, self.bounds, self.emitter = revision, series, bounds, emitter
self.cancelled = cancelled # Событие принадлежит одному заданию и не сбрасывается при запуске расчёта нового окна.
self.work_budget = WorkBudget(cancelled) # Короткие добровольные паузы предотвращают многосекундное ожидание GIL главным потоком.
def _check_cancelled(self): # Проверяем отмену на границах этапов и небольших блоков исходных отсчётов.
self.work_budget() # Одновременно прекращаем устаревшие измерения и регулярно освобождаем GIL для рисования и событий мыши.
def run(self):
stats = {}
try:
for key, times, values in self.series:
points = [(x, y) for x, y in viewport_samples(times, values, self.bounds.left, self.bounds.right)
if self.bounds.left <= x <= self.bounds.right]
stats[key] = measurements(points), len(points)
self._check_cancelled() # Не начинаем обход следующего канала после возобновления перемещения или изменения масштаба.
samples = _NavigationSamples(times, values, self.bounds, self._check_cancelled) # Повторяемый вид файлового окна устраняет копирование миллионов пар и сохраняет отмену чтения источника.
ordered = getattr(times, "ordered", isinstance(times, (list, tuple, range))) # Применяем тот же контракт упорядоченности, что и viewport_samples, включая смещённые файловые координаты.
points = tuple(samples) if isinstance(times, deque) or isinstance(values, deque) or not ordered else samples # Только живые очереди и неупорядоченный путь получают единый снимок для согласованных амплитуд и частоты.
result = measurements(points, self._check_cancelled) # Передаём отмену внутрь всех численных этапов, чтобы новый жест не ждал окончания полного среднего или СКЗ.
stats[key] = result, samples.count # Количество относится к завершённому проходу того же окна либо к первоначальному снимку живого ряда.
self._check_cancelled() # При отмене во время вычислений не продолжаем остальные каналы и не публикуем устаревшие значения.
self.emitter.ready.emit(self.revision, stats, self.bounds)
except Exception:
try:
@@ -6,6 +6,8 @@ from pathlib import Path
import subprocess
import tempfile
from threading import Lock
import math # Кодируем разрывы кривой нечисловыми значениями Y, сохраняя упорядоченные координаты X для нативного движка.
from set_devices.plot_decimation import pixel_envelope # Передаём движку только точки, различимые при текущем масштабе изображения.
try:
from PySide6.QtCore import QObject, QRunnable, Signal
except ImportError:
@@ -44,19 +46,26 @@ class NativePlotEngine:
self.process = subprocess.Popen([str(self.executable)], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, bufsize=0,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), cwd=self.executable.parent)
key = (id(times), id(values))
key = (id(times), id(values), len(times), len(values), bounds.left, bounds.right, width) # Выборка зависит от исходного снимка, окна X и физической ширины изображения; масштаб Y её не меняет.
record = self.records.get(key)
if record is None:
self.serial += 1
path = Path(self.directory.name) / f'{self.serial}.bin'
points = [] # Временный список ограничен экранной шириной и не содержит миллионов исходных измерений.
for point in pixel_envelope(times, values, bounds.left, bounds.right, width): # Сохраняем первый, минимальный, максимальный и последний отсчёты каждого пиксельного столбца.
if point is not None: # Конечные измерения передаются с исходными координатами и амплитудами.
points.append(point) # Экстремумы остаются видимыми даже для импульса длиной в один исходный отсчёт.
elif points and math.isfinite(points[-1][1]): # Повторные маркеры одного разрыва не должны увеличивать размер передаваемого ряда.
points.append((points[-1][0], math.nan)) # Дублированная метка времени и NaN в Y разрывают путь без нарушения порядка X.
with path.open('wb') as stream:
array('d', times).tofile(stream)
array('d', values).tofile(stream)
record = (path, times, values)
array('d', (point[0] for point in points)).tofile(stream) # Записываем только экранные координаты X в прежнем формате 64-битных чисел.
array('d', (point[1] for point in points)).tofile(stream) # Массив Y соответствует сокращённому X и включает маркеры разрывов.
record = (path, times, values, len(points)) # Ссылки сохраняют идентичность исходного снимка; длина описывает только передаваемый экранный ряд.
self.records[key] = record
if len(self.records) > 4:
self.records.pop(next(iter(self.records)))
request = dict(file=str(record[0]), count=len(times), width=width, height=height,
expired = self.records.pop(next(iter(self.records))) # Оставляем четыре последние экранные выборки, как и в кэше нативного процесса.
expired[0].unlink(missing_ok=True) # Освобождаем устаревший временный файл, чтобы навигация не накапливала данные на диске.
request = dict(file=str(record[0]), count=record[3], width=width, height=height, # Нативному процессу сообщается фактическое число отрисовываемых точек вместо полного размера записи.
left=bounds.left, right=bounds.right, bottom=bounds.bottom, top=bounds.top, color=color)
self.process.stdin.write((json.dumps(request)+'\n').encode())
self.process.stdin.flush()
@@ -0,0 +1,375 @@
"""Переиспользуемые Qt-панель и диалог сравнения с CSV-эталоном.""" # График, контейнер кривой и папки передаются потребителем.
import json # Отчёт сохраняет показатели и параметры сравнения в переносимом формате.
from pathlib import Path # Имя файла используется в статусе, полный путь — при записи отчёта.
import re # Единицы в скобках проверяются до сравнения каналов.
try: # Один порт поддерживает современные и совместимые Windows-сборки.
from PySide6.QtCore import QThread, Qt, Signal # Qt6 доставляет результаты рабочего потока в интерфейс.
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Используем стандартные элементы Qt6; создание ячеек с единицами и подсказками выполняет общий заполнитель таблицы.
except ImportError: # Qt5 доступен на системах, где Qt6 не поддерживается.
from PySide2.QtCore import QThread, Qt, Signal # Сохраняем тот же контракт сигналов и отмены на Qt5.
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Qt5 использует тот же заполнитель ячеек с единицами и подсказками, что современный порт.
from set_devices.signal_reference import compare, read_reference, trace_from_pairs # Единственное вычислительное ядро работает по всем исходным отсчётам.
from set_devices.qt_ports.circuit_parameters import CircuitParameters # Необязательные известные R/L/C ограничивают идентификацию электрического эквивалента.
from protocan.plot import Viewport # Общий контейнер области просмотра не зависит от конкретного виджета графика.
from set_devices.qt_ports.reference_results import ComparisonAnalysis, populate_comparison_table # Анализ, единицы показателей и подсказки одинаковы в отдельном и встроенном виде.
def _model_channels(sources, references, cache, cancel): # Подготовка связанных физических каналов выполняется только в рабочем потоке.
measured = {} # Сохраняем исходные временные метки для совместной проверки Uce/Uge/Ic.
for index, (signal, points) in enumerate(sources): # Истории и преобразования соответствуют одному снимку измерительной записи.
if re.match(r'^\s*(uce|uge|ic)\b', signal.name, re.IGNORECASE): # Не копируем произвольные дополнительные каналы, не участвующие в электрической модели.
if not points: # Отсутствующий связанный канал не должен ломать корректное сравнение выбранного измерения.
continue # Совместный анализ сообщит недостаточный набор физических наблюдений.
trace = cache.get(index) # Выбранный канал уже подготовлен основным сравнением.
if trace is None: # Остальные физические каналы преобразуются только при первом использовании.
trace = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Множители применяются в точности как на исходном графике.
cache[index] = trace # Следующая настройка RLC не требует повторной копии полной истории.
measured[str(index)] = trace # Индекс сохраняет неоднозначные одинаковые имена для проверки, не затирая их словарём.
return dict(measured=measured, reference=references) # Сдвиг и общие начала координат добавляет независимое вычислительное ядро.
def _stop_owned_worker(state): # Уничтожение родителя QWidget может обойти closeEvent вложенной панели.
worker = state[0] # Состояние не обращается к уже уничтожаемой Python-обёртке виджета.
if worker is not None: # Завершённые задания удаляются из держателя заранее.
worker.requestInterruption() # Операция увидит отмену через штатный callback QThread.
worker.wait() # QObject удаляет дочерний QThread только после выхода его run.
class ComparisonWorker(QThread): # Чтение CSV и численный проход выполняются вне главного потока Qt.
result = Signal(object) # Готовый результат передаётся интерфейсу без изменения исходных данных.
failed = Signal(str) # Ошибка передаётся текстом и не оставляет частичный отчёт.
def __init__(self, operation, parent): # Операция принимает callback проверки отмены.
super().__init__(parent) # Диалог владеет временем жизни рабочего потока.
self.operation = operation # Одинаковый worker обслуживает загрузку и расчёт.
def run(self): # Qt вызывает этот метод в отдельном потоке.
try: # Любая ошибка должна вернуться в UI, а не прервать главный цикл событий.
self.result.emit(self.operation(self.isInterruptionRequested)) # Передаём операции штатный запрос отмены QThread.
except (ValueError, OSError, InterruptedError) as error: # Ошибки данных, файлов и отмена уже содержат понятную причину.
self.failed.emit(str(error)) # Показываем исходную причину без дополнительной обёртки.
except Exception as error: # Неожиданная ошибка также должна завершить состояние занятости.
self.failed.emit(f'Ошибка сравнения: {error}') # Уточняем источник неизвестной ошибки для пользователя.
class _ReferenceComparisonView: # Панель и отдельный диалог используют одну реализацию параметров, worker и результата.
def __init__(self, sources, parent=None, source_path='', *, plot_factory, series_factory, path_provider=None): # sources содержит пары объекта с name/display_value и неизменяемой полной истории.
super().__init__(parent) # Родитель определяет окно и владение диалогом.
self._series_factory = series_factory # Фабрика адаптирует общие Trace к кривым графика потребителя.
self._dialog_path = path_provider or (lambda filename: filename) # Без callback используем стандартные относительные имена файлов.
self.setWindowTitle('Сравнение с эталоном') # Заголовок одинаков для всех приложений, использующих порт.
self.sources = sources # История фиксируется вызывающей стороной на время жизни диалога.
self.source_path = source_path # Происхождение измерения включается в JSON-отчёт.
self.references = {} # Каналы эталона появятся после успешного чтения CSV.
self.reference_path = '' # Путь изменяется только после принятия загруженного эталона.
self._source_cache = {} # Повторный расчёт не должен повторно применять множители к измерению.
self._worker = None # Одновременно допускается только одна загрузка или вычисление.
self._worker_state = [None] # Независимый держатель доступен обработчику destroyed без обращения к self.
self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # Останавливаем поток до удаления C++-детей даже при deleteLater родителя.
self._closing = False # Флаг откладывает закрытие до завершения отменённого worker.
self.comparison = None # До расчёта отчёт и график результата отсутствуют.
root = QVBoxLayout(self) # Вертикальный макет объединяет параметры, результат и экспорт.
row = QHBoxLayout() # Первая строка отвечает за выбор файлов и каналов.
self.load_button = QPushButton('Открыть эталон CSV…') # Эталон загружается отдельно от исходного измерения.
self.load_button.clicked.connect(self._open_reference) # Выбор файла запускает фоновое чтение.
self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Каналы измерения и эталона выбираются независимо.
for signal, _ in sources: # Показываем все переданные каналы с непустой историей.
self.source_combo.addItem(signal.name) # Имя берётся из общего контракта источника.
row.addWidget(self.load_button) # Кнопка открытия предшествует выбору каналов.
for label, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Подписи явно различают обе стороны сравнения.
row.addWidget(QLabel(label)) # Подпись относится к следующему списку каналов.
row.addWidget(control, 1) # Оба списка делят свободное место поровну.
root.addLayout(row) # Завершаем строку выбора данных.
row = QHBoxLayout() # Вторая строка задаёт временное совмещение и допуски.
self.align_start = QCheckBox('Совместить начала записей') # Позволяет сравнивать записи с разными абсолютными началами.
self.align_start.setChecked(True) # По умолчанию сравниваем форму от начала каждой записи.
self.align_start.setToolTip('Совмещает первые отсчёты. Задержка самого переключения сохраняется.') # Объясняем отличие совмещения начала от поиска фронта.
self.shift = self._number(-1e12, 1e12, 6) # Диапазон ±10^12 мкс и шесть знаков сохраняет прежнюю точность ввода сдвига.
self.shift.setSuffix(' мкс') # В интерфейсе время вводится в микросекундах.
self.shift.setToolTip('Положительное значение сдвигает эталон вправо') # Явно задаём знак временного сдвига.
self.absolute = self._number(0., 1e12, 6) # Абсолютный допуск неотрицателен и измеряется в единицах канала.
self.relative = self._number(0., 10000., 4) # Относительная часть допуска задаётся в процентах с четырьмя знаками.
self.relative.setSuffix(' %') # Единица отделяет проценты от абсолютной амплитуды.
row.addWidget(self.align_start) # Опция совмещения находится рядом со сдвигом.
for label, control in (('Сдвиг эталона', self.shift), ('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Подписи отражают сумму абсолютной и процентной частей.
row.addWidget(QLabel(label)) # Связываем пояснение с соответствующим полем.
row.addWidget(control) # Ограниченные по ширине поля оставляют место всем параметрам.
root.addLayout(row) # Добавляем настройки совмещения в общий макет.
row = QHBoxLayout() # Третья строка управляет интервалом, расчётом и видом результата.
self.limit_interval = QCheckBox('Только интервал, мкс') # Флажок включает ограничение расчёта по времени.
self.start, self.stop = self._number(-1e12, 1e12, 6), self._number(-1e12, 1e12, 6) # Границы используют ту же шкалу мкс и точность, что сдвиг.
self.stop.setValue(300.) # Начальный интервал 0–300 мкс сохраняет прежнее поведение диалога.
self.visible_button = QPushButton('Из видимого участка') # Текущий масштаб графика можно использовать как интервал расчёта.
self.visible_button.clicked.connect(self._visible_interval) # Переводим границы графика из мс в мкс.
self.calculate_button = QPushButton('Сравнить') # Расчёт запускается явно после выбора параметров.
self.calculate_button.clicked.connect(self._calculate) # Параметры фиксируются перед запуском worker.
self.cancel_button = QPushButton('Отмена расчёта') # Длинную загрузку или расчёт можно прервать.
self.cancel_button.clicked.connect(self._cancel) # Используем кооперативную отмену без остановки потока извне.
self.mode = QComboBox() # Режим отображения не меняет уже вычисленные показатели.
self.mode.addItems(['Наложение', 'Отклонение: измерение − эталон']) # Сначала показываем две кривые в общем масштабе.
self.mode.currentIndexChanged.connect(self._show_plot) # Переключаем кривые без повторного численного прохода.
for control in (self.limit_interval, self.start, QLabel('…'), self.stop, self.visible_button, self.calculate_button, self.cancel_button, self.mode): # Сохраняем порядок интервал → расчёт → вид.
row.addWidget(control) # Каждый элемент относится к одной строке управления результатом.
root.addLayout(row) # Завершаем область параметров.
self.status = QLabel('Откройте эталон. Сравниваются все отсчёты выбранного канала после преобразования амплитуды.') # Общий порт не предполагает конкретный формат преобразования источника.
self.status.setWordWrap(True) # Длинные ошибки CSV не расширяют окно.
root.addWidget(self.status) # Состояние видно над графиком.
self.circuit = CircuitParameters(self) # Неизвестные элементы по умолчанию не подменяются учебными номиналами.
self.circuit.changed.connect(self._invalidate) # Изменение электрического ограничения делает прежние параметры модели неактуальными.
root.addWidget(self.circuit) # Опциональные R/L/C доступны перед строкой анализа и результатами.
self.analysis = ComparisonAnalysis(self) # Строка возможной причины и подробный отчёт повторяют подход Android GUI.
root.addWidget(self.analysis) # Анализ располагается рядом с результатом, не изменяя исходные кривые.
self.legend = QLabel() # Цветная легенда поясняет текущие кривые.
root.addWidget(self.legend) # Легенда отделена от численной таблицы.
splitter = QSplitter(Qt.Orientation.Horizontal) # Пользователь меняет соотношение ширины графика и таблицы.
self.plot = plot_factory() # Конкретный canvas создаётся и настраивается адаптером приложения.
splitter.addWidget(self.plot) # QSplitter принимает любой совместимый QWidget графика.
self.table = QTableWidget(0, 2) # Каждому показателю соответствует название и значение.
self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Заголовки поясняют две колонки отчёта.
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Показатели расчёта недоступны для ручной правки.
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) # Названия и числа видны полностью по содержимому.
self.table.setMinimumWidth(370) # Минимум 370 логических пикселей сохраняет читаемость показателей.
splitter.addWidget(self.table) # Таблица располагается справа от кривых.
splitter.setSizes([920, 380]) # Начальные ширины в логических пикселях отдают большую часть окна графику.
root.addWidget(splitter, 1) # Результат получает всё свободное место по высоте.
row = QHBoxLayout() # Нижняя строка содержит экспорт и пояснение алгоритма.
self.report_button = QPushButton('Сохранить отчёт JSON…') # Экспорт доступен только для актуального результата.
self.report_button.clicked.connect(self._save_report) # Имя файла предлагает внедрённый provider путей.
row.addWidget(self.report_button) # Кнопка экспорта расположена перед пояснением.
note = QLabel('Δ = измерение − эталон. Линейная интерполяция эталона; без экстраполяции и заполнения пропусков.') # Формула и ограничения относятся ко всем приложениям-потребителям.
note.setWordWrap(True) # Текст остаётся читаемым при изменении размера окна.
row.addWidget(note, 1) # Пояснение занимает оставшуюся ширину строки.
root.addLayout(row) # Завершаем макет экспорта.
self._inputs = [self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, self.limit_interval, self.start, self.stop, self.visible_button] # Во время расчёта входные данные не меняются.
self._inputs.extend(self.circuit.controls) # Электрические ограничения также фиксируются на время рабочего задания.
for control in (self.shift, self.absolute, self.relative, self.start, self.stop): # Любое числовое изменение делает результат устаревшим.
control.valueChanged.connect(self._invalidate) # Сбрасываем старые кривые и отчёт до следующего расчёта.
for control in (self.align_start, self.limit_interval): # Флажки также влияют на вычисляемую временную сетку.
control.toggled.connect(self._invalidate) # Показатели не должны относиться к прежним условиям.
self.source_combo.currentIndexChanged.connect(self._match_reference) # При смене измерения ищем одноимённый эталон.
self.reference_combo.currentIndexChanged.connect(self._invalidate) # Смена эталонного канала требует нового расчёта.
self._busy(False) # Настраиваем начальную доступность кнопок.
QApplication.instance().aboutToQuit.connect(self._shutdown) # Завершение приложения дожидается отмены рабочего потока.
@staticmethod # Фабрика числового поля не зависит от состояния диалога.
def _number(low, high, decimals): # Границы и число десятичных знаков задаются для конкретной величины.
box = QDoubleSpinBox() # Штатный элемент проверяет числовой ввод.
box.setDecimals(decimals) # Сохраняем требуемую точность времени или допуска.
box.setRange(low, high) # Ограничиваем значения допустимым диапазоном параметра.
box.setMaximumWidth(150) # Ширина 150 логических пикселей сохраняет компактную строку параметров.
return box # Вызывающий код задаёт единицы и обработчик изменений.
def _busy(self, busy): # Единый переход состояния используется загрузкой и расчётом.
for control in self._inputs: # Все параметры относятся к одному снимку задания.
control.setEnabled(not busy) # Исключаем изменение входа работающего worker.
self.calculate_button.setEnabled(not busy and bool(self.references) and bool(self.sources)) # Без измерения или эталона расчёт невозможен.
self.cancel_button.setEnabled(busy) # Отменять можно только существующее задание.
self.report_button.setEnabled(not busy and self.comparison is not None) # Отчёт относится исключительно к завершённому результату.
def _start_work(self, operation, receive): # Операция и получатель отделяют чтение файла от численного расчёта.
self._busy(True) # Блокируем параметры до запуска потока.
worker = ComparisonWorker(operation, self) # Диалог сохраняет владение worker до его завершения.
self._worker = worker # Ссылка используется для отмены и отложенного закрытия.
self._worker_state[0] = worker # Сигнал уничтожения владельца получает актуальную задачу.
worker.result.connect(receive) # Результат применяется в главном потоке Qt.
worker.failed.connect(self.status.setText) # Ошибка появляется в общей строке состояния.
worker.finished.connect(self._finished) # Освобождение worker восстанавливает доступность элементов.
worker.start() # Начинаем фоновое чтение или расчёт.
def _finished(self): # Вызывается после штатного завершения и после отмены.
worker, self._worker = self._worker, None # Снимаем состояние занятости до возможного закрытия диалога.
self._worker_state[0] = None # Уничтожение родителя больше не обращается к завершённому worker.
if worker is None: # Синхронное закрытие могло уже очистить поток до доставки queued-сигнала.
return # Не повторяем удаление и не обновляем закрытую панель.
worker.deleteLater() # Qt безопасно удаляет завершившийся поток через очередь событий.
self._busy(False) # Разрешаем новый расчёт либо экспорт принятого результата.
if self._closing: # Запрос закрытия во время работы должен завершиться автоматически.
self.close() # Повторное закрытие уже не встречает активный worker.
def _cancel(self): # Отмена кооперативна и проверяется вычислительным ядром.
if self._worker is not None: # Не обращаемся к уже завершённому потоку.
self._worker.requestInterruption() # Ядро увидит запрос в ближайшей контрольной точке.
self.status.setText('Остановка расчёта…') # Пользователь видит, что запрос принят.
def _shutdown(self): # Перед выходом процесса нельзя уничтожать работающий QThread.
self._closing = True # Поздние сигналы не должны публиковать результат в закрывающемся интерфейсе.
if self._worker is not None: # Ожидание требуется только при активном задании.
worker = self._worker # Сохраняем объект до очистки общего держателя.
worker.requestInterruption() # Просим ядро прервать чтение или расчёт.
worker.wait() # Дожидаемся остановки до уничтожения объектов Qt.
self._worker = None # Смена записи может немедленно удалить закрытую панель.
self._worker_state[0] = None # Сигнал destroyed не обращается к удалённому потоку повторно.
worker.deleteLater() # Завершённый QThread безопасно освобождается очередью Qt.
def _invalidate(self, *_): # Аргументы сигналов Qt не нужны для сброса результата.
self.comparison = None # Старые показатели больше не соответствуют выбранным параметрам.
self.analysis.set_result(None) # После изменения ввода прежняя гипотеза больше не является актуальной.
self.table.setRowCount(0) # Убираем устаревшие численные значения.
self.report_button.setEnabled(False) # Запрещаем экспорт прежнего результата.
self.plot._series = [] # Старые кривые также не должны выглядеть актуальными.
self.legend.clear() # Легенда очищается вместе с кривыми.
self.plot._sync_bounds() # Адаптер графика пересчитывает пустую область просмотра.
self.plot.update() # Запрашиваем перерисовку в главном потоке.
if self.references: # После загрузки эталона подсказываем следующий шаг.
self.status.setText('Параметры изменены — нажмите «Сравнить».') # Новый результат получается только явным расчётом.
def _match_reference(self, *_): # Смена канала измерения ищет одноимённый столбец эталона.
name = self.source_combo.currentText().strip().casefold() # Регистр и крайние пробелы не мешают совпадению имён.
for index in range(self.reference_combo.count()): # Проверяем доступные каналы текущего эталона.
if self.reference_combo.itemText(index).strip().casefold() == name: # Сопоставляем полное имя вместе с единицами.
self.reference_combo.setCurrentIndex(index) # Предлагаем канал, соответствующий измерению.
break # Уникальные имена не требуют дальнейшего поиска.
self._invalidate() # Даже без совпадения старые показатели должны быть удалены.
def _open_reference(self): # Правила начальной папки задаёт callback приложения.
path, _ = QFileDialog.getOpenFileName(self, 'Открыть эталон', self._dialog_path(''), 'CSV (*.csv)') # Общая библиотека не создаёт каталоги самостоятельно.
if path: # Отмена выбора файла сохраняет текущий эталон.
self.load_reference(path) # Чтение выполняется отдельным worker.
def load_reference(self, path): # Публичный вход позволяет потребителю открыть эталон программно.
self._invalidate() # Новая загрузка не должна показывать старый отчёт как актуальный.
self.status.setText('Загрузка эталона…') # Состояние обновляется до запуска чтения.
def receive(traces): # Этот обработчик выполняется в потоке интерфейса.
if self._closing: # Синхронное закрытие могло опередить доставку готового чтения.
return # Не обновляем элементы закрываемой панели поздним результатом.
self.references = traces # Принимаем только полностью прочитанный набор каналов.
self.reference_path = str(path) # Путь отчёта соответствует принятому файлу.
self.reference_combo.clear() # Удаляем имена каналов предыдущего эталона.
self.reference_combo.addItems(list(traces)) # Сохраняем порядок столбцов CSV.
self._match_reference() # Автоматически выбираем одноимённый канал при наличии.
self.status.setText(f'Эталон: {Path(path).name}. Выберите каналы и нажмите «Сравнить».') # Подтверждаем успешную загрузку файла.
self._start_work(lambda cancel: read_reference(path, cancel), receive) # Ядро получает callback отмены чтения.
def _visible_interval(self): # Видимая область графика задаёт границы расчёта.
bounds = self.plot.visible_bounds # Контракт графика возвращает время в миллисекундах.
left, right = bounds.left * 1000, bounds.right * 1000 # Множитель 1000 переводит миллисекунды в микросекунды UI.
self.limit_interval.setChecked(True) # Явно включаем использование выбранных границ.
self.start.setValue(left) # Левая граница отсчитывается от начала измерения.
self.stop.setValue(right) # Правая граница выражена в той же шкале.
def _calculate(self): # Фиксируем параметры до передачи задания в поток.
if not self.references or not self.sources or self._worker is not None: # Не запускаем неполное или параллельное сравнение.
return # Сохраняем существующее задание и состояние входов.
source_index = self.source_combo.currentIndex() # Индекс связывает выбранное имя с исходным снимком.
signal, points = self.sources[source_index] # Полная история не зависит от экранного окна.
reference = self.references[self.reference_combo.currentText()] # Выбираем один канал загруженного эталона.
units = [re.search(r'\(([^()]*)\)\s*$', name) for name in (signal.name, reference.name)] # Единицы распознаются по конечным скобкам имени.
if all(units) and units[0].group(1) != units[1].group(1): # Явно разные единицы нельзя сравнивать как одинаковые амплитуды.
self._invalidate() # Предыдущий результат не относится к новому сочетанию каналов.
self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Пользователь выбирает корректную пару.
return # Не выполняем численный проход с несовместимыми единицами.
options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., # Деление на 1000 переводит микросекунды UI в миллисекунды ядра.
absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value(), # Допуски уже заданы в единицах ядра.
interval=(self.start.value() / 1000., self.stop.value() / 1000.) if self.limit_interval.isChecked() else None) # Ограничение времени также переводится из мкс в мс.
self._invalidate() # До расчёта удаляем старые кривые и показатели.
self.status.setText('Сравнение всех отсчётов…') # Явно указываем работу с полной записью.
options['circuit'] = self.circuit.values() # Значения GUI переводятся в СИ до перехода в рабочий поток.
sources, references = self.sources, self.references # Сохраняем единый снимок связанных каналов и выбранного CSV.
def operation(cancel): # Подготовка амплитуд и сравнение выполняются в worker.
measured = self._source_cache.get(source_index) # Кэш привязан к неизменяемому снимку канала.
if measured is None: # Преобразуем исходные значения только при первом использовании канала.
measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Применяем предоставленное источником преобразование амплитуд.
self._source_cache[source_index] = measured # Повторные настройки допуска не требуют новой подготовки измерения.
channels = _model_channels(sources, references, self._source_cache, cancel) # Синхронные Uce/Uge/Ic подготавливаются теми же преобразованиями амплитуд.
return compare(measured, reference, cancel=cancel, channels=channels, **options) # Общее ядро выполняет полную статистику и идентификацию электрических моделей.
self._start_work(operation, self._received) # Получатель публикует целый результат в главном потоке.
def _received(self, result): # Таблица и график обновляются из одного результата.
if self._closing: # Готовый результат мог стоять в очереди во время смены записи.
return # Закрытая панель не должна публиковать устаревшие метрики.
self.comparison = result # Сохраняем данные для переключения вида и экспорта.
self.analysis.set_result(result) # Краткий вывод обновляется вместе с полной таблицей сравнения.
populate_comparison_table(self.table, result) # Единицы и подсказки названий и чисел совпадают с боковой таблицей исходного графика.
self.status.setText(f'{result.measured.name} ↔ {Path(self.reference_path).name}: {result.reference.name}. ' # Статус указывает фактически сравниваемые каналы и файл.
'Время от начала измерения; абсолютные ошибки — в единицах выбранного канала.') # Поясняем шкалу и единицы результата.
self._show_plot() # Показываем текущий режим кривых без повторного расчёта.
def _show_plot(self, *_): # Сигнал выбора режима может передавать неиспользуемый индекс.
if self.comparison is None: # До успешного расчёта график результата отсутствует.
return # Не создаём кривые из устаревших данных.
result = self.comparison # Кривые и остаток принадлежат одному расчёту.
curves = [(result.measured, 'Измерение', '#2f91ff'), (result.reference, 'Эталон', '#ffae42')] # Синий и оранжевый различают стороны наложения.
self.legend.setText('<span style="color:#2f91ff">━━ Измерение</span> &nbsp; ' # Цвет легенды совпадает с измеренной кривой.
'<span style="color:#ffae42">━━ Эталон</span> — общий масштаб Y') # Адаптер обязан сохранить единую амплитудную шкалу.
if self.mode.currentIndex() == 1: # Второй режим показывает только рассчитанную разность.
curves = [(result.difference, 'Δ', '#ff7b7b')] # Красноватый цвет отделяет остаток от исходных кривых.
self.legend.setText('<span style="color:#ff7b7b">━━ Δ = измерение − эталон</span>') # Формула фиксирует знак ошибки.
self.plot._series = [self._series_factory(str(i), f'{label}: {trace.name}', color=color, timestamps=trace.times, values=trace.values) # Фабрика создаёт контейнер конкретного canvas без импорта приложения.
for i, (trace, label, color) in enumerate(curves)] # Передаём полные массивы без экранного сокращения.
self.plot.reset_interaction() # Новый вид начинает с нейтрального состояния навигации.
self.plot.axis_lock_x.setChecked(False) # Освобождаем X для корректного показа нового результата.
self.plot.axis_lock_y.setChecked(False) # Общий масштаб Y не наследует ручную фиксацию предыдущего режима.
self.plot.axis_limits = None # Удаляем прежние ручные границы осей.
self.plot._sync_bounds() # Адаптер рассчитывает общую область кривых.
interval = result.metrics['interval_ms'] # При ограниченном расчёте показываем выбранный интервал.
if interval is not None: # Полная запись остаётся видимой при отсутствии ограничения.
bounds = self.plot.visible_bounds # После сброса границы соответствуют полной области результата.
span = bounds.right - bounds.left # Нормируем миллисекунды в долю общей длительности.
self.plot.viewport = Viewport(x=(interval[0] - bounds.left) / span, width=(interval[1] - interval[0]) / span) # Масштаб X соответствует выбранному интервалу ядра.
self.plot.update() # Отложенная перерисовка выполняется главным потоком Qt.
def report(self): # Структуру можно получить программно без диалога сохранения.
if self.comparison is None: # Экспортировать устаревший или отсутствующий результат запрещено.
return None # Отсутствие результата обозначается явно.
return dict(measured_file=self.source_path, measured_channel=self.comparison.measured.name, # Источник измерения сохраняется рядом с выбранным каналом.
reference_file=self.reference_path, reference_channel=self.comparison.reference.name, # Эталонный файл и канал определяют вторую сторону сравнения.
time_unit='ms from first measured sample', # Машиночитаемый отчёт использует миллисекунды ядра.
interpolation='linear; no extrapolation; gaps excluded', # Ограничения интерполяции сохраняются вместе с результатом.
tolerance='absolute + relative_pct / 100 * abs(reference)', # Формула позволяет воспроизвести коридор допуска.
metrics=self.comparison.metrics, analysis=self.comparison.analysis) # Отчёт сохраняет также гипотезы, признаки и ограничения анализа.
def _save_report(self): # Сохранение файла выполняется только по явному действию пользователя.
if self.comparison is None: # Защищаемся от программного вызова без расчёта.
return # Не создаём пустой отчёт.
path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._dialog_path('reference_comparison.json'), 'JSON (*.json)') # Provider определяет начальную папку приложения.
if path: # Отмена выбора не меняет состояние результата.
try: # Файловые ошибки должны отображаться без закрытия диалога.
Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 и отступ в два пробела сохраняют читаемость русских имён.
self.status.setText(f'Отчёт сохранён: {path}') # Подтверждаем фактический путь записанного отчёта.
except OSError as error: # Недоступный каталог или файл не уничтожает рассчитанные данные.
self.status.setText(str(error)) # Пользователь может выбрать другое место и повторить сохранение.
class ReferenceComparisonPanel(_ReferenceComparisonView, QWidget): # Встроенный вид занимает область существующего графика без отдельного окна.
back_requested = Signal() # Потребитель решает, какую страницу показать при возврате к записи.
def __init__(self, *args, **kwargs): # Контракт данных и фабрик совпадает с отдельным диалогом.
super().__init__(*args, **kwargs) # Создаём общие параметры, таблицу и график через QWidget.
self.back_button = QPushButton('К записи') # Возврат сохраняет загруженный эталон и параметры скрытой панели.
self.back_button.clicked.connect(self.back_requested.emit) # Не связываем общую библиотеку с компоновкой конкретной вкладки.
row = QHBoxLayout() # Отдельная короткая строка не сжимает параметры сравнения.
row.addWidget(self.back_button) # Кнопка размещается у начала панели.
row.addStretch(1) # Свободная ширина остаётся справа от кнопки возврата.
self.layout().insertLayout(0, row) # Навигация предшествует выбору файлов и каналов.
def closeEvent(self, event): # Родитель может удалить панель сразу после close при смене записи.
self._closing = True # Сигнал завершения worker больше не должен возобновлять использование панели.
self._shutdown() # Синхронно отменяем и дожидаемся потока перед возможным deleteLater родителя.
self.plot.close() # Освобождаем ресурсы графика после завершения вычислений.
super().closeEvent(event) # QWidget принимает закрытие встроенной панели.
class ReferenceComparisonDialog(_ReferenceComparisonView, QDialog): # Отдельное окно остаётся доступным другим потребителям общего модуля.
def __init__(self, *args, **kwargs): # Используем тот же контракт источников и внедряемых фабрик.
super().__init__(*args, **kwargs) # Общая реализация строит элементы внутри QDialog.
self.setWindowModality(Qt.WindowModality.WindowModal) # Модальность ограничивается родительским окном.
self.resize(1350, 880) # Размер в логических пикселях сохраняет прежний макет отдельного окна.
def reject(self): # Escape и программное закрытие откладываются до остановки worker.
if self._worker is not None: # Работающий поток должен закончиться раньше удаления диалога.
self._closing = True # Запоминаем запрос закрытия до сигнала finished.
self._cancel() # Кооперативно прерываем операцию ядра.
return # Слот _finished завершит закрытие после остановки.
self.plot.close() # Освобождаем ресурсы конкретного canvas.
super().reject() # Сохраняем стандартный API и сигналы QDialog.
def closeEvent(self, event): # Системная кнопка закрытия соблюдает тот же порядок отмены.
if self._worker is not None: # Не уничтожаем QThread во время операции.
self._closing = True # Запоминаем отложенное закрытие.
self._cancel() # Запрос будет замечен ядром в контрольной точке.
event.ignore() # Диалог существует до завершения worker.
else: # Завершённый расчёт не препятствует закрытию.
self.plot.close() # График завершает собственную фоновую работу.
super().closeEvent(event) # Передаём QDialog штатное закрытие.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+175 -37
View File
@@ -7,8 +7,11 @@
from __future__ import annotations
from dataclasses import dataclass
from collections import deque # Храним ограниченную историю FD для отдельного просмотра диагностики.
from ..can_adapter_settings import SlcanOptions, FD_LENGTHS # Профиль прошивки определяет команды и допустимые длины CAN FD.
import re # Поиск ближайшего служебного байта за один проход сохраняет скорость при пакетном приёме большого числа CAN-кадров.
from .qt_compat import QObject, Signal
from .qt_compat import QObject, Signal, QElapsedTimer, QTimer # Таймеры ограничивают ожидание ответов и откладывают выдачу кадров до окончания настройки.
from .qt_compat import QSerialPort, QSerialPortInfo
@@ -33,11 +36,7 @@ class SlcanChannel:
return "slcan"
_BITRATE_COMMANDS = {
10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3",
125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7",
1_000_000: "S8",
}
_RECORD_END = re.compile(rb"[\r\n\x07]") # CR, LF и BEL имеют коды ASCII 13, 10 и 7; любой из них завершает запись протокола.
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
@@ -80,6 +79,7 @@ class SlcanAdapter(QObject):
frame_received = Signal(int, bytes)
capture_received = Signal(int, bytes, bool, bool)
connection_lost = Signal(str)
fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN.
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -88,10 +88,19 @@ class SlcanAdapter(QObject):
self._serial.errorOccurred.connect(self._error)
self._buffer = bytearray()
self._listen_only = False
self.options = SlcanOptions() # Без расширенных параметров остаётся прежний подтверждаемый протокол Lawicel.
self.fd_history = deque(maxlen=500) # Не более 500 последних FD-кадров сохраняется между открытиями окна просмотра.
self._query_prefix = None # Диагностические ответы отличаем от подтверждений настройки и CAN-кадров.
self._query_reply = None # Полная строка ответа сохраняется только во время явного диагностического запроса.
self._initializing = False # Открытие COM-порта ещё не подтверждает настройку CAN-контроллера.
self._waiting_command = False # Отличаем подтверждение текущей команды от служебных строк рабочего потока.
self._command_reply: bool | None = None # None означает ожидание, True — CR/ACK, False — BEL/отказ прошивки.
self._after_cr = False # Следующий LF после CR относится к тому же окончанию строки, даже при раздельных USB-пакетах.
self._pending_frames: list[tuple[int, bytes, bool]] = [] # Кадры при открытии CAN сохраняются до готовности подписчиков монитора.
@property
def connected(self) -> bool:
return self._serial.isOpen()
return self._serial.isOpen() and not self._initializing # Считаем соединение готовым только после подтверждения всех команд настройки.
def scan(self) -> list[SlcanChannel]:
# Windows не предоставляет надёжного признака протокола SLCAN.
@@ -102,11 +111,11 @@ class SlcanAdapter(QObject):
def connect_channel(self, channel: SlcanChannel, bitrate: int,
host_baudrate: int = 115200,
listen_only: bool = False) -> None:
listen_only: bool = False, *, options: SlcanOptions | None = None) -> None: # Расширенные параметры передаются отдельно от прежнего контракта подключения.
selected = options or SlcanOptions() # Дефолт сохраняет C, Sn и O/L с ожиданием CR/BEL.
commands = selected.commands(bitrate, listen_only) # Проверяем весь профиль до закрытия прежнего соединения и открытия COM.
self.disconnect_channel()
command = _BITRATE_COMMANDS.get(bitrate)
if command is None:
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
self.options = selected # Настройка ожидания команд нужна уже во время инициализации порта.
self._serial.setPortName(channel.path)
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
230400, 460800, 921600):
@@ -119,23 +128,67 @@ class SlcanAdapter(QObject):
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
self._buffer.clear()
self.fd_history.clear() # Журнал нового подключения не содержит сообщений прежнего устройства.
self._listen_only = listen_only
# Close first so reconnecting also works after an interrupted session.
open_command = "L" if listen_only else "O"
if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0:
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
raise SlcanError(f"Не удалось настроить SLCAN: {message}")
self._initializing = True # Во время настройки не выдаём ложный статус готовности и первые кадры с прежней скоростью CAN.
try: # Любой отказ настройки должен закрыть порт и разрешить повторную попытку подключения.
self._command("C", allow_rejection=True) # Закрываем прежний канал; BEL допустим, если канал уже закрыт по правилам Lawicel.
for command in commands: # Профиль определяет порядок скорости, режима, фильтров, повторов и открытия.
self._command(command) # Любой отказ прекращает настройку без опасного возврата к активному режиму.
except SlcanError: # Отказ прошивки, неполная запись и отсутствие ответа одинаково означают неудачное подключение.
self._serial.close() # Освобождаем COM-порт, чтобы пользователь мог исправить скорость или тип адаптера.
self._buffer.clear() # Остатки неудачного обмена не должны повреждать строки следующего подключения.
self._pending_frames.clear() # Не отправляем в монитор данные сеанса, который не удалось открыть.
self._listen_only = False # Сбрасываем ограничение режима вместе с неудачным подключением.
raise # Сохраняем точную причину отказа для статусной строки вызывающей вкладки.
finally: # Признак настройки должен сбрасываться как при успехе, так и при исключении.
self._initializing = False # После возврата вызывающая вкладка сможет установить правильную скорость и статус.
QTimer.singleShot(0, self._read) # Выдаём накопленные первые кадры в следующем цикле Qt, после обновления настроек монитора.
def _command(self, command: str, *, allow_rejection: bool = False) -> None: # Последовательный обмен сохраняет синхронный контракт подключения всех вкладок.
self._read() # Убираем ранее пришедшие ответы до начала ожидания новой команды.
self._command_reply = None # Старое подтверждение не может подтверждать следующую настройку.
self._waiting_command = True # Пустая строка CR или отдельный BEL теперь относятся к текущей команде.
packet = (command + "\r").encode("ascii") # Каждая команда Lawicel передаётся отдельной строкой с завершающим ASCII CR.
elapsed = QElapsedTimer() # Монотонное время не зависит от перевода системных часов Windows.
elapsed.start() # Общий срок ожидания включает запись команды и все фрагменты ответа.
try: # Флаг ожидания обязательно очищается при любом результате обмена.
if self._serial.write(packet) != len(packet): # Частичная локальная запись также не гарантирует передачу полной команды адаптеру.
raise SlcanError(f"SLCAN: не удалось передать {command}: {self._serial.errorString()}") # Указываем неотправленную команду и причину драйвера.
if self.options.profile in ("canable", "canable2"): # Эти прошивки не предоставляют CR/BEL для подтверждения конфигурации.
if self._serial.bytesToWrite() and not self._serial.waitForBytesWritten(self.options.command_timeout_ms): # Ждём фактическую передачу очереди Qt в драйвер, а не выдуманный ACK.
raise SlcanError(f"CANable: не удалось отправить {command}") # Ошибка локального драйвера не считается успешным подключением.
self._serial.waitForReadyRead(20) # Пауза до 20 мс даёт прошивке обработать отдельную команду USB CDC.
self._read() # Сохраняем уже пришедшие CAN-кадры, не требуя неподдерживаемого служебного ответа.
if self._command_reply is False or not self._serial.isOpen(): # Не игнорируем явный BEL и отключение даже в профиле без ACK.
raise SlcanError(f"CANable: отказ при выполнении {command}") # Пользователь получает конкретный этап неудачной настройки.
return # Статус интерфейса отдельно сообщает, что профиль не подтверждает команды.
while self._command_reply is None and self._serial.isOpen(): # CAN-кадры между служебными ответами не заменяют подтверждение команды.
remaining = self.options.command_timeout_ms - elapsed.elapsed() # Общий выбранный тайм-аут включает все фрагменты ответа команды.
if remaining <= 0: # Поток посторонних байтов не должен удерживать подключение в ожидании навсегда.
break # Далее сформируем понятную ошибку отсутствия SLCAN-ответа.
if self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining): # Используем уже поступившие байты или ждём только в пределах общего срока.
self._read() # Тот же обработчик разбирает ответы CR/BEL и сохраняет встреченные CAN-кадры.
else: # Драйвер завершил ожидание без новых данных либо порт был отключён.
break # Не запускаем повторный полный тайм-аут на ту же команду.
if not self._serial.isOpen(): # Физическое отключение во время настройки не является подтверждением команды.
raise SlcanError(f"SLCAN: порт закрыт при выполнении {command}: {self._serial.errorString()}") # Сообщаем о потере порта на конкретном этапе.
if self._command_reply is None: # Успешное открытие COM и запись в драйвер сами по себе не доказывают поддержку Lawicel.
raise SlcanError(f"SLCAN: нет ответа на {command}; проверьте тип адаптера и скорость COM") # Направляем пользователя к настройкам, влияющим на обмен ASCII-командами.
if not self._command_reply and not allow_rejection: # Только отказ повторного C при уже закрытом канале допускается протоколом настройки.
raise SlcanError(f"SLCAN: адаптер отклонил команду {command} (BEL)") # Отказ скорости или режима CAN больше не маскируется статусом подключения.
finally: # После команды подтверждения рабочего потока больше не меняют результат настройки.
self._waiting_command = False # Возвращаем обработчик приёма в обычный режим.
def disconnect_channel(self) -> None:
if not self._serial.isOpen():
return
self._serial.write(b"C\r")
self._serial.waitForBytesWritten(100)
self._serial.close()
self._buffer.clear()
self._listen_only = False
if self._serial.isOpen(): # Команда закрытия нужна только для реально открытого COM-порта.
self._serial.write(b"C\r") # Просим прошивку остановить CAN до освобождения порта.
self._serial.waitForBytesWritten(100) # Ограничиваем ожидание отправки закрытия ста миллисекундами, чтобы не задерживать выход.
self._serial.close() # Освобождаем порт независимо от доступности прошивки.
self._buffer.clear() # Частичная строка прежнего сеанса не должна приклеиваться к первому кадру нового подключения.
self._pending_frames.clear() # Отложенный обработчик Qt не должен выдавать кадры после отключения или переподключения.
self._after_cr = False # Новый сеанс начинает разбор окончаний строк без состояния предыдущего CR.
self._listen_only = False # Отключённый транспорт не сохраняет ограничение старого режима.
def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None:
if not self.connected:
@@ -149,24 +202,109 @@ class SlcanAdapter(QObject):
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")
def _read(self) -> None:
self._buffer.extend(bytes(self._serial.readAll()))
while b"\r" in self._buffer:
raw, _, remainder = self._buffer.partition(b"\r")
self._buffer[:] = remainder
frame = decode_frame(raw)
if frame is not None:
self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False)
self.frame_received.emit(*frame)
if not self._serial.isOpen(): # Отложенное чтение после закрытия не должно обращаться к неоткрытому драйверу.
return # Отключение уже очистило очередь и неполную строку.
self._buffer.extend(bytes(self._serial.readAll())) # Накапливаем фрагменты USB/COM до полного кадра или служебного ответа.
while self._buffer: # За одно чтение драйвер может вернуть несколько кадров и подтверждений команд.
if self._after_cr: # CR+LF считается одним окончанием, даже если LF пришёл в следующем чтении.
self._after_cr = False # Состояние относится только к ближайшему байту после CR.
if self._buffer[0] == 10: # ASCII LF имеет код 10 и не является началом следующего SLCAN-кадра.
del self._buffer[0] # Удаляем дополнительный LF, чтобы он не мешал распознаванию t/T и не давал второй ACK.
continue # Продолжаем с первого байта следующей записи, если он уже поступил.
boundary = _RECORD_END.search(self._buffer) # Ищем первый разделитель, не сканируя весь остаток пакета отдельно для отсутствующих LF и BEL.
if boundary is None: # Неполную строку нельзя декодировать до поступления окончания.
break # Сохраняем её в буфере для следующего readyRead.
end = boundary.start() # Ближайшая граница сохраняет исходный порядок кадров и подтверждений команд.
raw = bytes(self._buffer[:end]) # Получаем строку без служебного разделителя.
separator = self._buffer[end] # Тип разделителя отличает подтверждение команды от BEL.
del self._buffer[:end + 1] # Удаляем обработанную запись, не теряя оставшуюся часть потока.
self._after_cr = separator == 13 # ASCII CR имеет код 13; следующий LF принадлежит этому же окончанию.
if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды.
if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки.
self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами.
continue # BEL не должен приклеиваться к следующему CAN-кадру.
if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы.
continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам.
frame = decode_frame(raw) # Кадры STD/EXT декодируются прежним публичным кодеком.
if frame is not None: # Служебные строки и ответы прошивки не попадают в общий монитор CAN.
self._pending_frames.append((frame[0], frame[1], raw[:1] == b"T")) # Сохраняем данные и признак EXT, включая первые кадры во время открытия.
elif raw and self._query_prefix is not None and raw.startswith(self._query_prefix): # Диагностические строки распознаются только после исключения CAN-кадров.
self._query_reply = raw.decode("ascii", errors="replace") # Версия CANable не имеет префикса V, поэтому допускается пустой ожидаемый префикс.
elif not raw and self._waiting_command and self._command_reply is None: # Пустая строка CR означает ACK по Lawicel; строка с CAN-кадром не подтверждает команду.
self._command_reply = True # Разрешаем отправку следующей команды только после реального подтверждения.
if not self._initializing: # Первые кадры выдаются после возврата подключения, когда вкладка уже установила скорость CAN.
pending, self._pending_frames = self._pending_frames, [] # Отделяем очередь до вызова подписчиков, чтобы повторное чтение не дублировало кадры.
for can_id, data, extended in pending: # Выдаём все накопленные кадры в исходном порядке.
self.capture_received.emit(can_id, data, extended, False) # Полные метаданные наполняют общий CAN-монитор.
self.frame_received.emit(can_id, data) # Совместимый сигнал сохраняет работу существующих клиентов ProtoCAN.
def _error(self, error: QSerialPort.SerialPortError) -> None:
if error in (QSerialPort.SerialPortError.NoError,
QSerialPort.SerialPortError.NotOpenError):
return
if error in (QSerialPort.SerialPortError.NoError, # Сигнал NoError не означает потерю соединения.
QSerialPort.SerialPortError.NotOpenError, # Обращение после закрытия не требует повторного отключения.
QSerialPort.SerialPortError.TimeoutError): # Тайм-аут синхронного ожидания проверяет сама команда; порт при этом остаётся исправным.
return # Не превращаем отсутствие ответа на команду в ошибку физического соединения.
if self._serial.isOpen():
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
if not self._initializing: # При настройке исключение возвращает connect_channel, избегая повторного отключения из обработчика GUI.
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}") # Во время обычного приёма уведомляем вкладку о настоящем обрыве.
def close(self) -> None:
self.disconnect_channel()
def _receive_fd(self, raw: bytes) -> bool: # Результат отличает корректный FD от диагностической строки с похожей первой буквой.
extended = raw[:1] in (b"D", b"B") # Заглавная буква означает идентификатор длиной восемь HEX-цифр.
width = 8 if extended else 3 # Стандартный ID занимает три HEX-цифры в ASCII-протоколе.
try: # Сохраняем устойчивость потока при повреждённых USB/COM-строках.
identifier = int(raw[1:1 + width], 16) # Извлекаем числовой ID без префикса команды.
length = FD_LENGTHS[int(raw[1 + width:2 + width], 16)] # Четырёхбитный DLC переводится в фактическое число байтов.
payload = bytes.fromhex(raw[2 + width:].decode("ascii")) # Для CANable FD не документирован дополнительный суффикс временной метки.
except (ValueError, UnicodeError, IndexError): # Неполный ID, DLC или HEX-пара отклоняют только текущую запись.
return False # Неподходящая строка ещё может оказаться ответом версии CANable.
if len(payload) != length or not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF): # Не обрезаем лишние байты и не принимаем отрицательный ID или идентификатор другого формата.
return False # Повреждённые данные не должны выглядеть успешным приёмом.
record = (identifier, payload, extended, 2 | (4 if raw[:1] in (b"b", b"B") else 0), 0) # CANable не предоставляет аппаратную метку; ноль явно обозначает её отсутствие.
self.fd_history.append(("RX", record)) # История ограничена размером deque и доступна после открытия диагностики.
self.fd_received.emit(*record) # В мост и прикладной декодер Classic этот кадр не поступает.
return True # Вызывающий обработчик не должен повторно трактовать этот кадр как служебный ответ.
def send_fd(self, can_id: int, data: bytes, *, extended: bool = True, brs: bool = True) -> None: # Используем документированные команды расширения CANable 2.0.
if not self.connected or self._listen_only or not self.options.fd: # Проверка защищает передачу независимо от состояния кнопок GUI.
raise SlcanError("Передача FD требует активного подключения CAN FD") # Пассивный режим не допускает обхода через API.
if len(data) not in FD_LENGTHS or not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF): # Разрешаем только точные длины DLC без скрытого дополнения.
raise SlcanError("Неверный CAN ID или длина CAN FD (0…8, 12, 16, 20, 24, 32, 48, 64)") # Сообщаем фактический набор допустимых размеров.
prefix = ("B" if extended else "b") if brs else ("D" if extended else "d") # BRS выбирается буквой команды, а не полем ASCII-пакета.
packet = f"{prefix}{can_id:0{8 if extended else 3}X}{FD_LENGTHS.index(len(data)):X}{data.hex().upper()}\r".encode("ascii") # ID, DLC и данные образуют одну завершённую строку SLCAN.
if self._serial.write(packet) != len(packet): # Частичная запись не гарантирует доставку последнего CR в прошивку.
raise SlcanError("SLCAN не передал CAN FD") # Причина остаётся видимой в диагностическом окне отправки.
self.fd_history.append(("TX queued", (can_id, bytes(data), extended, 2 | (4 if brs else 0), 0))) # Локальная запись не выдаётся за подтверждение передачи на CAN.
def diagnostics(self) -> str: # Явная диагностика не меняет режим CAN и не выполняется фоновым таймером.
if not self.connected: # Чтение версии не должно захватывать ещё не выбранный COM-порт.
raise SlcanError("Сначала подключите адаптер") # Настройка и жизненный цикл подключения остаются под управлением пользователя.
lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel.
canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR.
if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии.
lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y.
for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки.
self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса.
self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии.
elapsed = QElapsedTimer() # Ограничиваем полный запрос монотонным временем, а не числом фрагментов.
elapsed.start() # Тайм-аут включает отправку и все поступившие строки CAN.
try: # Состояние запроса очищается также при ошибке записи или обрыве порта.
packet = (command + "\r").encode("ascii") # Все команды завершаются одиночным CR протокола Lawicel.
if self._serial.write(packet) != len(packet): # Локальная ошибка передачи должна быть видна вызывающему коду.
raise SlcanError(f"Не удалось отправить {command}") # Указываем конкретный отказавший диагностический запрос.
while self._query_reply is None and self.connected: # Приём CAN-кадров продолжается тем же обработчиком.
remaining = self.options.command_timeout_ms - elapsed.elapsed() # Поток кадров не должен бесконечно продлевать ожидание версии.
if remaining <= 0 or not (self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining)): # Отсутствие ответа завершает только текущий запрос.
break # Следующие строки диагностики покажут, какая команда не поддерживается.
self._read() # Ответ распознаётся по префиксу, а CAN продолжает поступать обычным подписчикам.
if command == "E" and self._buffer.startswith(b"CANable Error Register:"): # Документированная исходная прошивка может возвращать E без CR.
self._query_reply = bytes(self._buffer).decode("ascii", errors="replace") # Такой ответ принимаем только после завершения ожидания, не обрезая поток CAN.
self._buffer.clear() # Нетерминированная служебная строка не должна приклеиваться к следующему CAN-кадру.
lines.append(self._query_reply or f"{command}: нет ответа") # Отсутствие данных не заменяем выдуманными версией или нулевыми ошибками.
finally: # Следующий обычный приём не должен сохранять посторонние строки как ответ старого запроса.
self._query_prefix = None # Завершаем явную диагностическую транзакцию.
return "\n".join(lines) # Текст можно скопировать для анализа состояния устройства.
+42 -4
View File
@@ -11,12 +11,15 @@ from protocan import tms_firmware as wire
class TmsBootloader(QObject):
progress = Signal(int, str)
finished = Signal(bool, str)
request_sent = Signal(bytes)
def __init__(self, parent=None):
super().__init__(parent)
self._serial = QSerialPort(self)
self._serial.readyRead.connect(self._read)
self._serial.errorOccurred.connect(self._error)
self._owned_serial = self._serial
self._owns_serial = True
self._timeout = QTimer(self)
self._timeout.setSingleShot(True)
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
@@ -31,6 +34,7 @@ class TmsBootloader(QObject):
self._active = False
self._waiting = False
self._rx = bytearray()
self.readback = bytearray()
self._stage = ""
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
@@ -45,6 +49,8 @@ class TmsBootloader(QObject):
return
self._image, self._target = image, target
self._active = True
self._serial = self._owned_serial
self._owns_serial = True
self._serial.setPortName(port)
self._serial.setBaudRate(baud)
self._serial.setDataBits(QSerialPort.DataBits.Data8)
@@ -54,8 +60,29 @@ class TmsBootloader(QObject):
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self._finish(False, self._serial.errorString())
return
self._begin()
def start_connected(self, image, serial, target: wire.TmsTarget):
"""Use an existing port; its owner must forward RX and handle errors."""
if self._active:
return
try:
target.validate(len(image.data))
if not serial.isOpen() or serial.baudRate() <= 0:
raise ValueError("Подключите COM-порт в настройках 2812")
except ValueError as error:
self.finished.emit(False, str(error))
return
self._image, self._target = image, target
self._serial = serial
self._owns_serial = False
self._active = True
self._begin()
def _begin(self):
self.readback.clear()
self._response = None
self._steps = wire.programming_steps(image.data, target)
self._steps = wire.programming_steps(self._image.data, self._target)
self._advance()
def _advance(self):
@@ -66,6 +93,7 @@ class TmsBootloader(QObject):
except StopIteration:
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
else "Прошивка записана; проверка отключена" if not self._target.verify
else "Прошивка завершена, записанные данные проверены")
return
except (ValueError, RuntimeError) as error:
@@ -79,11 +107,15 @@ class TmsBootloader(QObject):
if self._serial.write(request) != len(request):
self._finish(False, "Ошибка передачи " + self._stage)
return
self.request_sent.emit(request)
if self._active:
self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate()))
self._timeout.start(timeout + int((len(request) + self._size) * 10000 / self._serial.baudRate()))
def _read(self):
chunk = bytes(self._serial.readAll())
self.receive(bytes(self._serial.readAll()))
def receive(self, chunk):
"""Forward fragments from a shared port without reading it twice."""
if not self._waiting:
return
self._rx.extend(chunk)
@@ -116,6 +148,10 @@ class TmsBootloader(QObject):
self._timeout.stop()
self._settle.stop()
self._waiting = False
if self._command == wire.CMD_UPLOAD and self._target.kind == "tms":
data = wire.decode_upload_reply(reply, self._target.controller, self._size - 8)
remaining = len(self._image.data) - len(self.readback)
self.readback.extend(data[:remaining])
self._response = reply
self._delay.start()
@@ -133,8 +169,10 @@ class TmsBootloader(QObject):
self._active = self._waiting = False
for timer in (self._timeout, self._settle, self._delay):
timer.stop()
if self._serial.isOpen():
if self._owns_serial and self._serial.isOpen():
self._serial.close()
if not success:
self.readback.clear()
if success:
self.progress.emit(100, message)
self.finished.emit(success, message)
@@ -1,5 +1,6 @@
"""Debounced viewport statistics shared by desktop charts."""
import time
from threading import Event # Сигнал отмены позволяет фоновому расчёту прекращать обработку старого окна при продолжении масштабирования.
from dataclasses import replace
try:
from PySide6.QtCore import QObject, QTimer, QThreadPool, Qt
@@ -16,6 +17,7 @@ class ViewportNavigation(QObject):
self.chart = chart
self._navigation_revision = 0
self._navigation_busy = False
self._navigation_cancel = Event() # Отдельное потокобезопасное событие относится к текущему фоновому расчёту измерений.
self._navigation_emitter = _NavigationSignals(self)
self._navigation_emitter.ready.connect(self._navigation_ready, Qt.ConnectionType.QueuedConnection)
self._navigation_timer = QTimer(self)
@@ -23,9 +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
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
self._navigation_cancel.set() # Сразу отменяем статистику предыдущего окна, чтобы она не конкурировала с новой отрисовкой миллионов отсчётов.
if not self.needs_worker(): # Проверяем суммарный объём тем же правилом, что используют панель измерений и легенда.
self.chart._scope_measurements()
self.chart._refresh_processing()
self.chart._refresh_legend()
@@ -35,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
@@ -43,15 +49,16 @@ class ViewportNavigation(QObject):
self._navigation_timer.start()
return
self._navigation_busy = True
self._navigation_cancel = Event() # После окончания перемещения создаём новый независимый сигнал отмены для актуального окна.
bounds = self.chart.plot.visible_bounds
panel = self.chart.processing
offset = self.chart._plot_time_offset if not self.chart.fft.isChecked() else 0.
# Navigation changes the next calculation's domain; retained curves keep
# their original domain. Avoid preparing millions of inputs on the UI.
panel._snapshot = replace(panel._snapshot, x_range=(bounds.left + offset, bounds.right + offset))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if not s.discrete]
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds,
self._navigation_emitter))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if s.visible and not s.discrete] # Скрытые каналы не занимают процессор повторным расчётом статистики при перемещении графика.
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds, # Фоновый расчёт использует полные исходные значения только актуального видимого окна.
self._navigation_emitter, self._navigation_cancel)) # Передаём событие отмены вместе с существующими ревизией и каналом результата.
def _navigation_ready(self, revision, stats, bounds):
self._navigation_busy = False
+36 -15
View File
@@ -57,7 +57,14 @@ class WaveTransfer(QRunnable):
result = protocol.status(words)
if result['version'] >= 2:
result = protocol.status(words, exchange(8)[0])
return result
try: # Дополнительные регистры объявляют XY независимо от версии прежнего восьмисловного статуса.
channels,selected,maximum,other=exchange(12) # Четыре слова 0x1309…0x130C читаются общим C-кодеком.
result.update(channels=channels,selected=selected,max_channels=maximum,received_other=other) # Сохраняет фактические возможности порта и состояние обеих таблиц.
except ValueError as error: # Старые WG v1/v2/v3 отвечают исключением адреса либо имеют прежний native-кодек.
if "(-102)" not in str(error) and "Недопустимая команда" not in str(error): # Ошибки CRC и структуры ответа нельзя скрывать как одноканальную плату.
raise # Сохраняет штатный отказ передачи при повреждённом ответе.
result.update(channels=1,max_channels=1) # Старый клиент и прошивка сохраняют одноканальную работу без новых записей.
return result # Возможности XY проверяются до stop и изменения таблиц устройства.
status = read_status() # Positive device identification before any write.
if self.action == "upload":
@@ -66,27 +73,40 @@ class WaveTransfer(QRunnable):
raise ValueError("Таблица не помещается в память устройства")
if status['version'] == 1 and wave.sample_rate > 50000:
raise ValueError("Для частоты выше 50 000 отсчётов/с обновите прошивку МК: нужна карта генератора версии 2")
channel_codes=(wave.codes,) if not hasattr(wave,"codes_y") else (wave.codes,wave.codes_y) # Одна временная сетка содержит один канал либо обе координаты XY.
if len(channel_codes)>status["max_channels"]: # Устройство обязано явно подтвердить аппаратный порт второго канала.
raise ValueError("Для XY нужны прошивка F407/HOPE и native-кодек templates с поддержкой двух каналов") # Отказ происходит до остановки ранее работающего выхода.
if any(len(codes)!=len(wave.codes) for codes in channel_codes): # Нельзя загружать пару с разными длинами даже из вручную созданного задания.
raise ValueError("X и Y должны иметь одинаковую длину") # Не меняет оборудование при несовместимой временной сетке.
exchange(1)
if status["max_channels"]>=2: # Новый порт явно сбрасывает формат перед каждой загрузкой, включая возврат XY→mono.
exchange(10,value=len(channel_codes)) # Выбирает число одновременно воспроизводимых каналов и инвалидирует обе прежние таблицы.
if status['version'] >= 2:
exchange(9, value=wave.sample_rate >> 16)
exchange(2, value=wave.sample_rate & 0xffff)
exchange(3, value=len(wave.codes))
blocks = status['version'] >= 3 and getattr(port, 'supports_wave_blocks', False)
step = 120 if blocks else 1
for index in range(0, len(wave.codes), step):
values = wave.codes[index:index + step]
if blocks:
transfer(protocol.block_request(index, values), protocol.block_response)
else:
exchange(4, index, values[0])
self.signals.progress.emit(int((index + len(values)) * 50 / len(wave.codes)))
for index in range(0, len(wave.codes), step):
values = wave.codes[index:index + step]
actual = (transfer(protocol.block_request(index, count=len(values)), protocol.block_response)
if blocks else exchange(7, index))
if tuple(actual) != tuple(values):
raise ValueError(f"Проверка таблицы не пройдена: отсчёты с {index}")
self.signals.progress.emit(50 + int((index + len(values)) * 50 / len(wave.codes)))
total=len(wave.codes)*len(channel_codes)*2 # Прогресс учитывает запись и чтение каждого кода обеих координат.
completed=0 # Общий счётчик исключает откат процентов при переходе от X к Y.
for channel,codes in enumerate(channel_codes): # Загружает и проверяет X, затем Y без промежуточного пуска.
if status["max_channels"]>=2: # Прежняя прошивка не получает неподдерживаемый селектор окна.
exchange(11,value=channel) # Одинаковые block/sample команды адресуют выбранную координату через общий регистр 0x130A.
for reading in (False,True): # Обратное чтение всей координаты обязательно до общего commit пары.
for index in range(0,len(codes),step): # Блочный транспорт ограничен прежними 120 кодами на запрос.
values=codes[index:index+step] # Последний блок содержит только действительные отсчёты без дополнения.
if reading: # Проверка использует ту же границу блока и тот же селектор, что запись.
actual=transfer(protocol.block_request(index,count=len(values)),protocol.block_response) if blocks else exchange(7,index) # Читает реальные коды из памяти устройства.
if tuple(actual)!=tuple(values): # Любое расхождение запрещает общий commit и дальнейший пуск.
raise ValueError(f"Проверка {'XY'[channel]} не пройдена: отсчёты с {index}") # Показывает ошибочную координату и начало блока.
elif blocks: # SET v2 переносит тот же атомарно проверяемый RTU-блок из общего ядра templates.
transfer(protocol.block_request(index,values),protocol.block_response) # Подтверждает запись всего блока до следующего запроса.
else: # Старый UART/RTU клиент сохраняет поотсчётную загрузку без смены wire-контракта.
exchange(4,index,values[0]) # Записывает очередной код выбранной координаты.
completed+=len(values) # Каждый подтверждённый код учитывается ровно один раз на фазу.
self.signals.progress.emit(int(completed*100/total)) # Общий прогресс монотонно достигает 100 % после проверки обеих таблиц.
if status["max_channels"]>=2: # Итоговый статус возвращается к X для одинакового отображения mono и XY.
exchange(11,value=0) # Не меняет содержимое пар и не запускает аппаратный вывод.
exchange(5)
elif self.action == "start":
if not status["ready"]:
@@ -98,6 +118,7 @@ class WaveTransfer(QRunnable):
raise ValueError("Неизвестное действие")
result = read_status()
if self.action == "upload" and (not result["ready"] or result["running"] or
result.get("channels",1)!=(2 if hasattr(self.waveform,"codes_y") else 1) or # Итоговое число каналов обязано совпасть с подготовленным mono/XY заданием.
result["count"] != len(self.waveform.codes) or result["rate"] != self.waveform.sample_rate):
raise ValueError("Устройство не подтвердило загруженную таблицу")
if self.action in ("start", "stop") and result["running"] != (self.action == "start"):
+131
View File
@@ -0,0 +1,131 @@
"""Локальные гипотезы неисправностей по сравнению осциллограмм с эталоном.""" # Правила не требуют сети, модели ИИ или знания имени файла.
import math # Конечность отсчётов и масштабы защищают признаки от пропусков и нулевого размаха.
import re # Обозначения физических каналов позволяют отделить Uce, Uge и Ic.
from set_devices.signal_fault_models import identify_fault_models # Параметрические модели конкурируют по BIC и ошибке контрольных наблюдений.
def _role(name): # Название файла не используется как доказательство неисправности.
match = re.match(r'^\s*(uce|uge|ic)\b', name, re.IGNORECASE) # Узнаём только явные обозначения напряжений ключа, затвора и тока.
return match.group(1).lower() if match else '' # Неизвестные обозначения получают только общие статистические гипотезы.
class _Edges: # Гистерезис отделяет полное повторное переключение от небольшого звона и шума.
def __init__(self, low, high): # Пороги задаются относительно размаха эталона в единицах канала.
self.low, self.high = low, high # Нижняя и верхняя границы определяют устойчивые состояния.
self.state, self.start, self.previous = None, None, None # Начальное состояние и разрыв не считаются фронтами.
self.events, self.count = [], 0 # Полный счётчик дополняется ограниченным списком для оценки задержки.
def reset(self): # Через NaN или большой разрыв нельзя восстанавливать ненаблюдавшееся переключение.
self.state, self.start, self.previous = None, None, None # После разрыва ждём нового устойчивого состояния.
def add(self, stamp, value): # Последовательный обход не создаёт дополнительную копию многомиллионной записи.
state = 0 if value <= self.low else 1 if value >= self.high else None # Зона между порогами сохраняет предыдущее устойчивое состояние.
if self.state is None: # Первая наблюдаемая полка задаёт исходное состояние.
self.state = state # Попадание только в середину фронта ещё не определяет направление.
elif state == self.state: # Возврат к прежней полке отменяет незавершённый переход.
self.start = None # Шум внутри полосы не накапливает ложную длительность фронта.
else: # Сигнал покинул прежнюю полку и может достичь противоположной.
if self.start is None: # Запоминаем начало только один раз на каждый фронт.
self.start = self.previous if self.previous is not None else stamp # Шаг дискретизации ограничивает точность начала перехода.
if state is not None: # Полное пересечение гистерезиса подтверждает переключение.
self.count += 1 # Считаем все наблюдаемые фронты независимо от длины записи.
if len(self.events) < 256: # Первых 256 переходов достаточно для сводки; память остаётся ограниченной.
self.events.append(((self.start + stamp) / 2., stamp - self.start, state)) # Сохраняем середину, длительность в мс и направление фронта.
self.state, self.start = state, None # Новая полка становится исходной для следующего перехода.
self.previous = stamp # Предыдущая временная метка нужна для оценки длительности на дискретной сетке.
def analyze_comparison(result, cancel=lambda: False, *, circuit=None, channels=None): # Подбор получает необязательные параметры электрического эквивалента и связанные физические каналы.
metrics = result.metrics # Покрытие и допуски уже рассчитаны по полным исходным данным.
measured_role, reference_role = _role(result.measured.name), _role(result.reference.name) # Физические роли важнее совпадения единицы «В».
findings, notes = [], [] # Несколько одновременно наблюдаемых признаков сохраняются в одном отчёте.
features = {} # Численные признаки экспортируются для проверки и воспроизведения решения.
model_report = {} # До проверки входных данных никакая математическая модель не считается идентифицированной.
def finish(summary, attention=True): # Краткая строка и подробности всегда относятся к одному результату.
details = [summary, f'Каналы: {result.measured.name} ↔ {result.reference.name}.', # Обозначаем фактически выбранные измерение и эталон.
f'Сравнено {metrics["count"]} отсчётов; покрытие {metrics["coverage_pct"]:.4g}%; за допуском {metrics["outside_tolerance"]}.', # Не смешиваем непокрытые точки с превышениями.
f'RMSE {metrics["rmse"]:.7g}; максимум |Δ| {metrics["max_abs"]:.7g} в единицах канала.', # Ошибка сохраняет физические единицы выбранной пары.
*[item['text'] for item in findings if item['text'] != summary], *notes, # Совместные причины и ограничения сохраняются без повторения единственного краткого вывода.
'Диагностические гипотезы основаны на параметрических моделях. BIC и контрольная ошибка оценивают согласие модели с данными, но не доказывают отказ конкретного элемента.'] # Отделяем идентификацию динамики от причинного диагноза оборудования.
if features: # Наблюдаемые численные признаки доступны только после полного прохода по данным.
details.insert(4, f'Полных переходов: эталон {features["reference_edges"]}, измерение {features["measured_edges"]}; смен знака после выключения {features["turn_off_reversals"]}; выброс после выключения {features["turn_off_overshoot"]:.7g} в единицах канала.') # Объясняем гипотезы дребезга и снаббера фактическими числами.
for model in model_report.get('models', []): # Подробности показывают также конкурентов выбранного объяснения.
details.append(f'Модель {model["code"]}: {model["formula"]}\nПараметры: {model["parameters"]}\nRMSE={model["rmse"]:.6g}; контроль={model["validation_rmse"]:.6g}; BIC={model["bic"]:.6g}; ΔBIC={model["delta_bic"]:.6g}; объяснено ошибки {model["explained_error_pct"]:.3g}%.') # Каждый вывод сопровождается фактическими параметрами и штрафом за сложность.
for pair in model_report.get('ringing', []): # Отклики измерения и эталона имеют отдельные динамические оценки.
for side in ('measured', 'reference'): # Не выдаём качество одного сигнала за проверку другой стороны сравнения.
model = pair.get(side) # Эталон может не содержать пригодного колебательного участка.
if model: # Отсутствующая оценка не форматируется как нулевая частота или затухание.
details.append(f'RLC {side}: {model["parameters"]}\nR²={model["r_squared"]}; контроль RMSE={model["validation_rmse"]:.6g}; ΔBIC={model["delta_bic"]:.6g}; модель подтверждена: {model["supported"]}.') # Приводим качество именно подбора затухающего отклика.
details.append(f'Эквивалентный контур: {pair["equivalent"]}') # Введённые элементы, восстановленные значения и неопределённость сохраняются в отчёте.
joint = model_report.get('joint', {}) # Совместная электрическая проверка не должна теряться в одном только JSON.
if joint: # Даже отсутствие каналов является существенным ограничением модели.
details.append(f'Совместные модели Uce/Uge/Ic: {joint}') # Сохраняем результаты ВАХ, остаточного тока, совпадения провалов и причины неидентифицируемости.
details.extend(model_report.get('assumptions', [])) # Предположения BIC, выборки и эквивалентной топологии видны рядом с результатами.
if model_report.get('criteria'): # Критерии поддержки модели не должны оставаться неявными константами кода.
details.append(f'Начальные критерии принятия моделей: {model_report["criteria"]}. Это настройки анализа, не паспортные нормы оборудования.') # Отделяем математическую идентификацию от выбранной чувствительности диагностического решения.
return dict(summary=summary, details='\n\n'.join(details), attention=attention, findings=findings, features=features, model_report=model_report, version=2) # Новый отчёт сохраняет совместимые поля и добавляет структурированные математические модели.
def finding(code, text): # Коды не зависят от русской формулировки и позволяют проверять сценарии.
findings.append(dict(code=code, text=text)) # Сохраняем причину вместе с объясняющим наблюдением.
if measured_role and reference_role and measured_role != reference_role: # Uce и Uge нельзя диагностировать как один физический сигнал даже при одинаковых единицах.
return finish('Выбраны разные физические каналы; сопоставьте одинаковые Uce, Uge или Ic.') # Не выдаём ложную неисправность при ошибке выбора на экране.
units = [re.search(r'\(([^()]*)\)\s*$', trace.name) for trace in (result.measured, result.reference)] # Проверяем явные единицы также при независимом вызове ядра без Qt.
if all(units) and units[0].group(1) != units[1].group(1): # Одинаковые обозначения с разными единицами не позволяют строить аппаратные гипотезы.
return finish('Единицы каналов различаются; сопоставьте сигналы с одинаковыми единицами.') # Численные показатели доступны, но физическая причина не определяется.
if metrics['coverage_pct'] < 100.: # Любое неполное покрытие ограничивает вывод областью реально наблюдаемых данных.
notes.append('Покрытие неполное: пропуски и время вне эталона исключены; состояние всей записи не определено.') # Даже нулевая ошибка на остатке не подтверждает исправность всей записи.
if metrics['count'] < 16: # Минимум 16 общих точек нужен для распознавания формы, а не для вычисления RMSE.
return finish('Недостаточно общих отсчётов для гипотезы неисправности.') # Короткий набор не должен давать уверенное заключение.
low, high = metrics['reference_min'], metrics['reference_max'] # Пределы относятся к интерполированному эталону выбранного интервала.
span = high - low # Размах определяет относительные пороги переключения.
scale = max(span, abs(low), abs(high), 1e-12) # Для постоянной ненулевой полки используем её уровень; 10^-12 исключает деление на ноль.
switching_span = span >= .5 * scale # Полный переход должен занимать не менее половины масштаба; малый звон около 400 В не является включением ключа.
expected_edges = _Edges(low + .3 * span, low + .7 * span) # Уровни 30% и 70% подавляют малые колебания на полках.
measured_edges = _Edges(expected_edges.low, expected_edges.high) # Одинаковые пороги позволяют сравнивать число и длительность переходов.
minimum, maximum, step = math.inf, -math.inf, math.inf # Границы измерения и минимальный шаг рассчитываются без дополнительного массива.
previous, residual_sign, reversals = None, 0, 0 # Знак значимого остатка нужен для распознавания затухающего звона.
off_sign, off_reversals, off_overshoot = 0, 0, 0. # Снаббер проверяется по колебаниям после выключения Uce, отдельно от помех на включённой полке.
band = max(.02 * scale, metrics['absolute_tolerance']) # 2% масштаба и абсолютный допуск отсекают небольшую погрешность и фоновый шум.
for index, (stamp, expected, delta) in enumerate(zip(result.difference.times, result.expected, result.difference.values)): # Анализируем каждый сравниваемый отсчёт без экранного прореживания.
if index % 16384 == 0 and cancel(): # Проверка отмены порциями по 16384 точки сохраняет отзывчивость больших записей.
raise InterruptedError('Анализ отменён') # Устаревшие гипотезы не публикуются после смены записи.
if not math.isfinite(expected) or not math.isfinite(delta): # Пропуск обрывает и фронт, и последовательность знаков остатка.
expected_edges.reset(); measured_edges.reset() # Обе стороны имеют общую область достоверного наблюдения.
previous, residual_sign = None, 0 # Через NaN нельзя считать смену знака или длительность фронта.
off_sign = 0 # Пропуск также прерывает подтверждение звона после выключения.
continue # Некорректные значения не влияют на признаки.
if previous is not None: # Первый корректный отсчёт ещё не определяет шаг времени.
distance = stamp - previous # Внутренняя единица времени — миллисекунда.
if distance > 5. * step: # Разрыв более пяти минимальных шагов не интерполируется диагностикой.
expected_edges.reset(); measured_edges.reset() # Не считаем фронт через ненаблюдавшийся промежуток времени.
residual_sign = 0 # Звон после большого разрыва начинается новой последовательностью.
off_sign = 0 # Колебания по разные стороны временного разрыва не образуют одну последовательность.
step = min(step, distance) # Минимальный наблюдаемый шаг задаёт нижнюю границу временной точности.
value = expected + delta # Восстанавливаем амплитуду на общей сетке без поиска по исходному массиву.
minimum, maximum = min(minimum, value), max(maximum, value) # Выброс сравнивается с огибающей эталона.
if switching_span: # Небольшие колебания отдельной полки не должны считаться полными переключениями транзистора.
expected_edges.add(stamp, expected); measured_edges.add(stamp, value) # Считаем переходы обеих кривых на одной временной сетке.
sign = 1 if delta > band else -1 if delta < -band else 0 # Малая ошибка внутри полосы не образует новую полуволну звона.
if sign: # Нулевая зона между полуволнами сохраняет последний значимый знак.
reversals += int(residual_sign != 0 and residual_sign != sign) # Смена положительного и отрицательного отклонения подтверждает колебание.
residual_sign = sign # Следующая значимая полуволна сравнивается с текущей.
if expected_edges.count and expected_edges.state == 1 and expected >= expected_edges.high: # Для Uce высокая полка после наблюдаемого фронта соответствует выключенному транзистору.
off_overshoot = max(off_overshoot, value - high, low - value) # Выброс на включённой полке не является признаком неисправности снаббера.
if sign: # Для звона учитываем только значимые знакопеременные отклонения на выключенной полке.
off_reversals += int(off_sign != 0 and off_sign != sign) # Пересчитываем смены знака отдельно от всех помех записи.
off_sign = sign # Последняя полуволна сохраняется до следующего выключенного отсчёта.
else: # Включённая полка и область фронта прерывают проверяемую последовательность звона.
off_sign = 0 # Шум между переключениями не связывается со следующим выключением.
previous = stamp # Временная непрерывность проверяется только по корректным точкам.
if cancel(): # Проверяем отмену также после последней неполной порции.
raise InterruptedError('Анализ отменён') # Не возвращаем результат закрытой панели.
features.update(reference_edges=expected_edges.count, measured_edges=measured_edges.count, residual_reversals=reversals, # Количественные признаки объясняют гипотезы дребезга и звона.
overshoot=max(0., maximum - high, low - minimum), turn_off_reversals=off_reversals, turn_off_overshoot=off_overshoot, sample_step_ms=step if math.isfinite(step) else None) # Общий выброс отделён от признаков после выключения; шаг выражен в мс.
physical = bool(measured_role and measured_role == reference_role) # Электрические гипотезы допустимы только для одинаковых явно обозначенных физических каналов.
model_report, findings = identify_fault_models(result, expected_edges.events, features, physical, measured_role, circuit, channels, cancel) # Конкуренты подбираются по данным и проверяются на отложенных точках.
if metrics['outside_tolerance'] == 0 and not findings: # Совпадение выбранного Uce не скрывает нарушение, подтверждённое другими синхронными каналами.
summary = 'Отклонений от эталона в заданных допусках не обнаружено.' if metrics['absolute_tolerance'] > 0. or metrics['relative_tolerance_pct'] > 0. else 'Сигнал совпадает с эталоном; допуски не заданы.' # Совпадение формы не является полной проверкой исправности устройства.
return finish(summary + (' Покрытие неполное.' if notes else ''), bool(notes)) # Сохраняем ограничение неполного покрытия и параметры найденных моделей.
if metrics['absolute_tolerance'] == 0. and metrics['relative_tolerance_pct'] == 0.: # Нулевой коридор должен быть явно отличим от заданного норматива.
notes.append('Допуски не заданы: любое ненулевое отклонение считается превышением; задайте погрешность измерения.') # Обычный шум не должен восприниматься как доказанный аппаратный отказ.
summary = ' '.join(item['text'] for item in findings) # Разные модели совместного дефекта остаются видимыми в одной строке.
return finish(summary + (' Покрытие неполное.' if metrics['coverage_pct'] < 100. else '')) # Краткий вывод связан с тем же покрытием, что полный математический отчёт.
+18
View File
@@ -0,0 +1,18 @@
import ctypes as C # Загружаем необязательный настольный модуль через стабильный C ABI.
from functools import lru_cache # Удерживаем DLL и её функции, пока расчётные задачи используют указатели.
import os # Разрешаем явно выбрать альтернативную библиотеку в тестах или нестандартной установке.
from pathlib import Path # Ищем модуль рядом с уже выбранной основной SETProtocol DLL.
import sys # Расширение нативного модуля соответствует текущей настольной ОС.
@lru_cache(maxsize=1) # Один экземпляр DLL безопасно обслуживает независимые расчёты без глобальных рабочих буферов.
def library(core_path): # Путь основной библиотеки задаёт согласованный каталог установки обоих модулей.
name = {"win32": "setsignal_eigen.dll", "darwin": "libsetsignal_eigen.dylib"}.get(sys.platform, "libsetsignal_eigen.so") # Отдельное имя сохраняет возможность запуска с прежней основной DLL.
path = Path(os.environ.get("SETSIGNAL_EIGEN_LIBRARY", str(Path(core_path).resolve().with_name(name)))) # Явный путь окружения имеет приоритет над соседним компонентом приложения.
try: # Отсутствующий или несовместимый компонент показывается пользователю как ошибка выбранного варианта.
result = C.CDLL(str(path)) # Загружаем только явно вычисленный путь, не подбирая случайную DLL из PATH.
pointer = C.POINTER(C.c_double) # Входные и выходные массивы содержат значения double общего алгоритма.
result.set_signal_reconstruct_eigen.argtypes = [pointer, pointer, C.c_size_t, C.c_int, C.c_uint, C.c_size_t, C.c_int, pointer, pointer, pointer, pointer, C.c_size_t] # Сигнатура повторяет исходную реконструкцию и сохраняет размер size_t для x86/x64.
result.set_signal_reconstruct_eigen.restype = C.c_int # Ошибки ранга и нехватки памяти возвращаются целочисленным кодом.
return result # Удерживаем объект DLL для всего времени жизни привязанной функции.
except (AttributeError, OSError) as error: # Отдельно обрабатываем отсутствие экспорта и невозможность загрузки библиотеки.
raise ValueError("Eigen QR недоступен: соберите модуль cpp/signal-eigen/build.py для разрядности приложения") from error # Не подменяем явно выбранный Eigen текущим МНК.
File diff suppressed because one or more lines are too long
+79
View File
@@ -0,0 +1,79 @@
"""Совместные электрические модели Uce, Uge и Ic на общей временной сетке.""" # Наличие трёх каналов даёт дополнительные проверяемые ограничения аппаратных гипотез.
import math # Корни дисперсий задают оценку неопределённости коэффициентов проводящего состояния.
import re # Физические каналы определяются по явным обозначениям, а не имени CSV.
from statistics import median # Медианные уровни и MAD устойчивы к отдельным коммутационным выбросам.
from set_devices.signal_fault_models import checkpoint, fit_model, interpolate # Переиспользуем численные методы без зависимости от GUI и циклического импорта ядра.
def _channels(traces): # Дублирующиеся обозначения нельзя выбирать произвольно для физических расчётов.
found = {} # Для каждой физической роли разрешён только один однозначный канал.
for trace in traces.values(): # Имена и значения предоставляются снимком исходной записи.
match = re.match(r'^\s*(uce|uge|ic)\s*\((V|A)\)\s*$', trace.name, re.IGNORECASE) # Требуем явные единицы В для напряжений и А для тока.
if match and match.group(2).lower() == ('a' if match.group(1).lower() == 'ic' else 'v'): # Не используем ток в мА как амперы без явного преобразования.
role = match.group(1).lower() # Внутренние обозначения не зависят от регистра заголовка.
found[role] = trace if role not in found else None # Повторная роль помечается неоднозначной и не участвует в выводе.
return {role: trace for role, trace in found.items() if trace is not None} # Неоднозначность не маскируется выбором первого столбца.
def fit_joint_channels(result, channels, events=None, cancel=lambda: False): # Все связанные каналы интерполируются на общую фактическую сетку сравнения.
if not channels: # Синхронные осциллограммы могут отсутствовать у одиночного источника.
return dict(available=False, reason='Для совместной проверки нужны Uce (V), Uge (V) и Ic (A) измерения и эталона.'), [] # Не подменяем отсутствующий ток или затвор выдуманными данными.
measured, reference = _channels(channels.get('measured', {})), _channels(channels.get('reference', {})) # Две стороны сравнения проверяются независимо.
if any(role not in measured or role not in reference for role in ('uce', 'uge', 'ic')): # Физические ограничения требуют полного набора каналов с однозначными единицами.
return dict(available=False, reason='Недостаточно однозначных синхронных каналов Uce/Uge/Ic в В/А.'), [] # Пропущенный канал остаётся ограничением диагностического вывода.
count = len(result.difference.times) # Общая область уже ограничена интервалом и временем эталона.
stride = max(1, math.ceil(count / 4096)) # До 4096 точек ограничивают дополнительную стоимость совместной электрической модели.
points = [] # Каждая строка содержит синхронные тройки фактического измерения и эталона.
for index in range(0, count, stride): # Изменение масштаба графика не влияет на используемую область записи.
if index % 1024 == 0: # Порционная проверка отмены поддерживает отзывчивость на больших записях.
checkpoint(cancel) # Не продолжаем подготовку после смены источника.
stamp = result.difference.times[index] # Время отсчитывается от первого измеренного отсчёта в миллисекундах.
a = {role: interpolate(trace.times, trace.values, stamp + channels['measured_origin_ms']) for role, trace in measured.items()} # Сохраняем временное взаимное положение измеренных каналов.
b = {role: interpolate(trace.times, trace.values, stamp + channels['reference_origin_ms'] - channels['shift_ms']) for role, trace in reference.items()} # К эталону применяется тот же пользовательский сдвиг, что в основном сравнении.
if all(value is not None for value in (*a.values(), *b.values())): # Только полностью наблюдаемые тройки пригодны для совместной физической проверки.
points.append((stamp * 1000., a, b)) # Человеко-читаемое время событий хранится в микросекундах.
if len(points) < 30: # Дополнительные модели требуют не менее 30 совместных наблюдений.
return dict(available=False, reason='Недостаточно общих наблюдений трёх физических каналов.'), [] # Число точек явно ограничивает возможность идентификации.
low = min(point[2]['uce'] for point in points) # Низкая полка эталона определяет проводящее состояние транзистора.
high = max(point[2]['uce'] for point in points) # Высокая полка соответствует выключенному ключу.
gate_high = max(point[2]['uge'] for point in points) # Опорный высокий уровень затвора определяется по данным эталона.
on = [point for point in points if point[2]['uce'] < low + .02 * (high - low) and point[1]['uce'] < low + .25 * (high - low) and min(point[1]['uge'], point[2]['uge']) > .98 * gate_high] # ВАХ оценивается на устойчивой эталонной полке с высоким затвором и фактически проводящим измеренным ключом.
off = [point for point in points if min(point[1]['uce'], point[2]['uce']) > low + .85 * (high - low) and max(point[1]['uge'], point[2]['uge']) < .3 * gate_high] # Утечка допустима как модель только при высоком Uce и низком Uge с обеих сторон; пробой с низким Uce рассматривается отдельно.
report, findings = dict(available=True, count=len(points), on_count=len(on), off_count=len(off), contact_events=[]), [] # Совместные выводы сохраняют фактическую область подтверждения.
fits = {} # Две модели проводимости отдельно оценивают исправный и измеренный ключ.
for side, position in (('measured', 1), ('reference', 2)): # Коэффициенты U0 и Rd вычисляются из собственных тока и напряжения каждой стороны.
if len(on) < 30: # Мало наблюдений проводящего состояния не позволяет оценить наклон ВАХ.
continue # Не выдаём эквивалентное сопротивление по одному значению тока.
currents = [point[position]['ic'] for point in on] # Ток задаётся в амперах, напряжение — в вольтах.
if max(currents) - min(currents) < .1 * max(max(abs(value) for value in currents), 1e-12): # Размах менее 10% уровня не разделяет надёжно U0 и дифференциальное сопротивление.
continue # Почти постоянная нагрузка не даёт идентифицировать две независимые величины.
model = fit_model('conduction', 'Uce=Rdiff*Ic+U0', [currents, [1.] * len(on)], [point[position]['uce'] for point in on], ['resistance_ohm', 'voltage_offset_v']) # Модель является локальной линейной ВАХ, а не постоянным сопротивлением IGBT во всех режимах.
if model: # QR-решатель дополнительно проверяет независимость тока и свободного члена.
training_current = [value for index, value in enumerate(currents) if index % 3 != 1] # Оценка неопределённости использует только наблюдения, участвовавшие в подборе коэффициентов.
average = sum(training_current) / len(training_current) # Центр обучающей выборки определяет ковариацию наклона и свободного члена.
spread = sum((value - average) ** 2 for value in training_current) # Знаменатель ковариации имеет единицу А² и не увеличивается отложенными контрольными точками.
model['slope_standard_error_ohm'] = math.sqrt(model['training_sse'] / max(1, len(training_current) - 2) / spread) # Стандартная ошибка оценивается по обучающим остаткам при независимом шуме и не покрывает систематическую ошибку датчика.
fits[side] = model # Сохраняем полные параметры и контрольную ошибку каждой ВАХ.
report['conduction'] = fits or dict(identifiable=False, reason='Нужны устойчивое включённое состояние и заметное изменение тока нагрузки.') # Неидентифицируемый наклон явно отличим от нулевого сопротивления.
if len(fits) == 2: # Сравнение деградации требует оценок обеих сторон на одном режиме нагрузки.
delta = fits['measured']['parameters']['resistance_ohm'] - fits['reference']['parameters']['resistance_ohm'] # Изменение дифференциального сопротивления измеряется в омах.
uncertainty = math.hypot(fits['measured']['slope_standard_error_ohm'], fits['reference']['slope_standard_error_ohm']) # Независимые статистические ошибки наклонов складываются по дисперсии.
report['conduction_change_ohm'] = delta # Численная разница доступна даже при недостаточной статистической значимости.
if delta > max(5. * uncertainty, 1e-9): # Порог пяти стандартных ошибок ограничивает реакции на один только случайный шум.
findings.append(dict(code='conduction', text=f'Совместная модель ВАХ: дифференциальное сопротивление выросло на {delta:.4g} Ом; возможны деградация ключа или сопротивление силового контакта.', model='Uce=Rdiff*Ic+U0')) # Обе физические причины дают похожий наклон и остаются альтернативами.
if len(on) >= 30 and len(off) >= 30: # Разность токов включённого и выключенного состояний отделяет утечку от постоянного нуля датчика.
on_error = [point[1]['ic'] - point[2]['ic'] for point in on] # Ошибка тока во включённом состоянии служит проверкой общей калибровки.
off_error = [point[1]['ic'] - point[2]['ic'] for point in off] # Остаточный ток при низком затворе является кандидатом модели утечки.
on_level, off_level = median(on_error), median(off_error) # Медианы подавляют отдельные импульсы коммутации.
noise = max(1e-9, 1.4826 * median([abs(value - on_level) for value in on_error] + [abs(value - off_level) for value in off_error])) # Множитель 1,4826 переводит MAD в оценку СКО нормального шума.
report['leakage'] = dict(formula='ΔIc_off=Ileak+b; ΔIc_on=b', off_current_a=off_level, on_offset_a=on_level, contrast_a=off_level - on_level, noise_sigma_a=noise) # Двухрежимная модель явно оценивает постоянную ошибку датчика b.
if off_level > 5. * noise and off_level - on_level > 5. * noise: # Одновременно нужен положительный ток выключения и отличие от общей ошибки нуля.
findings.append(dict(code='leakage', text=f'Совместная модель остаточного тока: при низком Uge дополнительный Ic≈{off_level - on_level:.4g} А; возможны утечка ключа или параллельная токовая цепь.', model='off_current')) # Посторонняя ветвь тока остаётся альтернативой утечке самого транзистора.
for event in events or []: # Сегменты предполагаемого дребезга проверяются по синхронным затвору и току.
interval = [point for point in points if event['start_us'] <= point[0] < event['stop_us']] # Проверяем только реально наблюдаемые точки внутри найденного прерывания.
if not interval: # Слишком короткий эпизод может не попасть в ограниченную совместную выборку.
continue # Отсутствие наблюдений не считается отсутствием провала.
gate_ratio = sum(point[1]['uge'] < .5 * point[2]['uge'] for point in interval) / len(interval) # Доля одновременного провала управления количественно проверяет причину повторного выключения.
current_ratio = sum(abs(point[1]['ic']) < .5 * abs(point[2]['ic']) for point in interval) / len(interval) # Провал тока ключа дополнительно подтверждает прерывание проводимости.
report['contact_events'].append(dict(**event, gate_drop_fraction=gate_ratio, current_drop_fraction=current_ratio, supported=gate_ratio >= .8 and current_ratio >= .8)) # Подтверждение требует совпадения обоих провалов хотя бы в 80% наблюдений эпизода.
return report, findings # Одновременные нарушения проводимости, утечки и контакта не скрывают друг друга.
+22 -8
View File
@@ -8,6 +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": "Восстановление: кубический сплайн"}
@@ -19,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():
@@ -34,29 +40,37 @@ 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):
if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= 10000:
raise ValueError("Неизвестный метод или число выходных точек вне 2…10000")
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)
if not 2 <= count <= 100000 or type(degree) is not int or not 1 <= degree <= 5:
raise ValueError("Нужно 2…100000 исходных точек, степень 1…5")
x, y = (C.c_double * count)(*(p[0] for p in points)), (C.c_double * count)(*(p[1] for p in points))
ox, oy, meta = (C.c_double * output_count)(), (C.c_double * output_count)(), (C.c_double * 3)()
work = (C.c_double * (14 * count + 128))()
code = library().set_signal_reconstruct(x, y, count, list(METHODS).index(method), degree,
output_count, int(endpoint), ox, oy, meta, work, len(work))
core = library() # Существующая библиотека остаётся источником базового алгоритма и каталога установленного модуля.
operation = core.set_signal_reconstruct # По умолчанию сохраняем исходный C99-решатель и его бинарный контракт.
if backend == "eigen": # Загружаем дополнительный компонент только после явного выбора пользователя.
from .signal_eigen import library as eigen_library # Ленивый импорт не требует наличия Eigen при обычной работе и в прошивках.
operation = eigen_library(core._name).set_signal_reconstruct_eigen # Соседняя DLL реализует тот же контракт входов, выходов и work.
code = operation(x, y, count, list(METHODS).index(method), degree, output_count, int(endpoint), ox, oy, meta, work, len(work)) # Оба решателя получают одинаковые данные и настройки сетки.
if code:
raise ValueError({1: "Недостаточно точек для выбранной степени или неверные параметры",
2: "Нужны конечные значения и минимум две различные временные точки",
3: "Неустойчивая аппроксимация: уменьшите степень"}.get(code, "Ошибка расчёта"))
3: "Неустойчивая аппроксимация: уменьшите степень", 5: "Недостаточно памяти или ошибка решателя Eigen"}.get(code, "Ошибка расчёта")) # Дополнительная настольная ошибка не меняет прежние коды C99.
pieces = ()
if with_model:
from .plot_formula import reconstruction_pieces
if method == 'polynomial' and output_count <= degree:
pieces = reconstruct(points, method, degree + 1, degree, with_model=True).pieces
pieces = reconstruct(points, method, degree + 1, degree, endpoint=endpoint, with_model=True, backend=backend).pieces # Короткий выход требует отдельной сетки для формулы; сохраняем решатель и режим конечной точки.
else:
pieces = reconstruction_pieces(work, count, int(meta[1]), method, degree, oy, endpoint)
return Reconstruction(list(zip(ox, oy)), int(meta[0]), int(meta[1]), meta[2], pieces)
result_points = list(zip(ox, oy)) # Включаем формирование выходных Python-точек в отображаемое время обработки.
return Reconstruction(result_points, int(meta[0]), int(meta[1]), meta[2], pieces, backend, (perf_counter() - started) * 1000) # Переводим секунды монотонного таймера в миллисекунды без влияния изменения системных часов.
def dac12(volts, vref=3.3):
data = (C.c_double * len(volts))(*volts)
+168
View File
@@ -0,0 +1,168 @@
"""Сравнение полной записи с CSV-эталоном независимо от Qt и приложения.""" # Ядро сохраняет все исходные отсчёты.
from array import array # Массивы double компактно хранят амплитуды без потери точности.
from dataclasses import dataclass, field # Контейнеры связывают кривые, показатели и независимый словарь анализа каждого результата.
from set_devices.signal_diagnostics import analyze_comparison # Общий локальный анализ работает без GUI и выполняется вместе со сравнением в worker.
import csv # Потоковый parser поддерживает разделители и кавычки CSV.
from datetime import datetime # Абсолютное ISO-время переводится в миллисекунды.
import math # Проверки конечности отделяют корректные измерения от пропусков.
@dataclass # Создаём конструктор независимого от GUI контейнера кривой.
class Trace: # Временная шкала каждого канала измеряется в миллисекундах.
name: str # Имя канала используется в выборе, легенде и отчёте.
times: list # Строго возрастающие метки могут быть общими для каналов CSV.
values: array # Амплитуды в единицах канала; NaN обозначает разрыв записи.
@dataclass # Передаём вычисленный результат между слоями приложения единым объектом.
class Comparison: # Полные кривые отделены от экранной огибающей.
measured: Trace # Измерение со временем относительно первого отсчёта.
reference: Trace # Эталон после совмещения начал и временного сдвига.
difference: Trace # Остаток на общей части временной сетки измерения.
expected: array # Интерполированный эталон на сетке остатка, включая пропуски.
metrics: dict # Показатели ошибки и параметры воспроизводимого расчёта.
analysis: dict = field(default_factory=dict) # Необязательное поле сохраняет совместимость прежнего конструктора с пятью аргументами.
def checkpoint(cancel): # Callback позволяет прерывать расчёт без зависимости от потоковой библиотеки.
if cancel(): # Проверяем запрос в контрольных точках прохода по данным.
raise InterruptedError('Расчёт отменён') # Не публикуем частичный результат после отмены.
def trace_from_pairs(name, points, transform=float, cancel=lambda: False): # Преобразование амплитуд задаёт потребитель, например множитель и IQ.
times, values = [], array('d') # Сохраняем время списком, амплитуды — 64-битными double.
previous = -math.inf # Первая конечная метка допустима независимо от её знака.
for index, (stamp, value) in enumerate(points): # Обходим полную историю без экранного сокращения.
if index % 16384 == 0: # Проверяем отмену порциями по 16384 точки для снижения накладных расходов.
checkpoint(cancel) # Прерываем подготовку по запросу вызывающей стороны.
if not math.isfinite(stamp) or stamp <= previous: # Повторы и обратный ход времени делают интерполяцию неоднозначной.
raise ValueError('Время должно строго возрастать, без повторов') # Не исправляем исходную сетку молча.
times.append(stamp) # Дробное время сохраняется без округления.
values.append(transform(value)) # Применяем масштаб до сравнения ровно один раз.
previous = stamp # Следующая точка должна находиться строго правее текущей.
if not times: # Пустая запись не имеет начала для совмещения.
raise ValueError('В канале нет отсчётов') # Сообщаем отсутствие исходных данных.
return Trace(name, times, values) # Возвращаем снимок, независимый от изменяемого живого буфера.
def read_reference(path, cancel=lambda: False): # Путь задаётся потребителем без правил хранения конкретного приложения.
"""Читает CSV потоково, сохраняя пустые ячейки и NaN как пропуски.""" # Разрывы впоследствии исключаются из статистики.
with open(path, encoding='utf-8-sig', newline='') as stream: # Поддерживаем UTF-8 с необязательным BOM и штатную обработку CSV-переносов.
first = stream.readline() # Заголовок определяет разделитель столбцов.
delimiter = ';' if ';' in first else '\t' if '\t' in first else ',' # Принимаем точку с запятой, табуляцию и запятую.
stream.seek(0) # Заголовок должен также попасть в CSV-parser.
rows = csv.reader(stream, delimiter=delimiter) # Читаем построчно без хранения текста всего файла.
header = [s.strip() for s in next(rows, [])] # Пробелы вокруг имён не являются частью имени канала.
if len(header) < 2 or header[0] not in ('time_ms', 'timestamp'): # Требуем явную единицу времени и хотя бы один канал.
raise ValueError('Эталон: первый столбец должен быть time_ms или timestamp') # Не угадываем шкалу неизвестного формата.
names = header[1:] # Остальные столбцы содержат амплитуды каналов.
if any(not name for name in names) or len(set(names)) != len(names): # Имена должны однозначно определять столбец.
raise ValueError('Эталон: требуются непустые уникальные имена каналов') # Предотвращаем потерю повторного столбца в словаре.
times = [] # Одна временная сетка разделяется всеми каналами файла.
traces = {name: Trace(name, times, array('d')) for name in names} # Каждый канал получает собственный массив амплитуд.
for index, row in enumerate(rows, 2): # Первая строка данных имеет номер 2 после заголовка.
if index % 16384 == 0: # Проверяем отмену раз в 16384 строки большого CSV.
checkpoint(cancel) # Закрытие окна не требует дочитывать весь файл.
if len(row) != len(header): # Каждая строка должна соответствовать заявленным столбцам.
raise ValueError(f'Эталон, строка {index}: неверное число столбцов') # Номер строки помогает найти повреждение.
if header[0] == 'time_ms': # Относительное время уже выражено во внутренних миллисекундах.
stamp = float(row[0]) # Сохраняем дробные доли миллисекунды.
else: # timestamp задаёт абсолютное ISO-время с часовым поясом.
date = datetime.fromisoformat(row[0].replace('Z', '+00:00')) # Суффикс UTC нормализуется также для Python 3.8.
if date.tzinfo is None: # Время без пояса зависит от настроек компьютера.
raise ValueError('timestamp эталона должен содержать часовой пояс') # Исключаем неоднозначное абсолютное время.
stamp = date.timestamp() * 1000 # Множитель 1000 переводит UNIX-секунды в миллисекунды.
if not math.isfinite(stamp) or (times and stamp <= times[-1]): # Сетка обязана быть конечной и строго возрастающей.
raise ValueError(f'Эталон, строка {index}: время должно строго возрастать') # Не сортируем и не удаляем отсчёты автоматически.
times.append(stamp) # Добавляем общую метку всех каналов текущей строки.
for trace, text in zip(traces.values(), row[1:]): # Порядок каналов соответствует порядку заголовка.
value = float(text) if text.strip() else math.nan # Пустая ячейка обозначает разрыв, а не нулевую амплитуду.
if math.isinf(value): # Бесконечность считается повреждением данных, в отличие от NaN.
raise ValueError(f'Эталон, строка {index}: бесконечное значение') # Указываем строку недопустимой амплитуды.
trace.values.append(value) # Сохраняем каждое значение без децимации.
if len(times) < 2: # Для линейной интерполяции необходим хотя бы один отрезок.
raise ValueError('Эталон должен содержать хотя бы две временные точки') # Одноточечный эталон не публикуется.
checkpoint(cancel) # Проверяем отмену и для короткого файла перед возвратом.
return traces # Выбор конкретного канала остаётся вызывающей стороне.
def compare(measured, reference, *, align_start=True, shift_ms=0., # Положительный сдвиг задерживает эталон на заданные миллисекунды.
interval=None, absolute_tolerance=0., relative_tolerance_pct=0., # Интервал отсчитывается от начала измерения; допуск состоит из двух частей.
cancel=lambda: False, circuit=None, channels=None): # Необязательные параметры контура и связанные каналы расширяют анализ без изменения прежних вызовов.
"""Интерполирует эталон на сетку измерения без экстраполяции и заполнения NaN.""" # Статистика использует все корректные исходные точки.
if not measured.times or len(reference.times) < 2: # Нужны начало измерения и две опоры эталона.
raise ValueError('Недостаточно данных для сравнения') # Пустые данные не должны давать нулевую ошибку.
if any(not math.isfinite(x) for x in (shift_ms, absolute_tolerance, relative_tolerance_pct)): # Неконечные параметры нарушают координаты и допуск.
raise ValueError('Параметры сравнения должны быть конечными числами') # Сообщаем причину до начала прохода.
if absolute_tolerance < 0 or relative_tolerance_pct < 0: # Коридор допуска имеет неотрицательную ширину.
raise ValueError('Допуск должен быть неотрицательным') # Отрицательный допуск не имеет физического смысла.
if interval is not None and (not all(math.isfinite(x) for x in interval) or interval[0] >= interval[1]): # Обе границы должны образовывать конечный непустой интервал.
raise ValueError('Начало интервала должно быть меньше конца') # Не переставляем ошибочно введённые границы.
origin = measured.times[0] # Начало измерения задаёт ноль результирующей шкалы времени.
ref_origin = reference.times[0] if align_start else origin # Опция совмещения отделяет относительное сравнение от абсолютного.
mt = [t - origin for t in measured.times] # Нормализуем временную сетку измерения без изменения амплитуд.
rt = [t - ref_origin + shift_ms for t in reference.times] # Совмещаем начало и применяем сдвиг эталона вправо.
left, right = interval if interval is not None else (mt[0], mt[-1]) # По умолчанию используем полную длительность измерения.
times, differences, expected = [], array('d'), array('d') # Остаток и интерполированный эталон получают общую сетку.
count = candidates = outside = invalid = beyond = 0 # Считаем сравнения, выбранные точки, отсутствие покрытия, пропуски и превышения.
mean = m2 = mean_abs = mean_square = 0. # Потоковые накопители среднего, дисперсии, MAE и среднего квадрата.
peak, peak_time, first_outside = -1., None, None # Начальный максимум ниже нуля регистрирует и полностью совпадающие кривые.
ref_min, ref_max = math.inf, -math.inf # Размах определяется на реально сравниваемой части эталона.
j = 0 # Указатель отрезка эталона перемещается только вперёд.
rv = reference.values # Локальная ссылка уменьшает накладные расходы основного прохода.
for index, (stamp, value) in enumerate(zip(mt, measured.values)): # Перебираем каждый исходный отсчёт измерения.
if index % 16384 == 0: # Проверка раз в 16384 точки ограничивает стоимость поддержки отмены.
checkpoint(cancel) # Прерываем устаревший или отменённый расчёт.
if stamp < left or stamp > right: # Границы интервала включаются, внешние точки исключаются.
continue # Посторонние участки записи не влияют на статистику.
candidates += 1 # Покрытие относится ко всем выбранным точкам измерения.
if stamp < rt[0] or stamp > rt[-1]: # Вне временной сетки эталона экстраполяция запрещена.
outside += 1 # Отдельно учитываем отсутствие покрытия по времени.
continue # Не подставляем крайнее значение за пределами записи.
while j + 1 < len(rt) and rt[j + 1] <= stamp: # Находим левую опору без повторного поиска с начала.
j += 1 # Однонаправленное продвижение сохраняет линейную сложность.
target = rv[j] # Точное совпадение с узлом не зависит от соседних пропусков.
if stamp != rt[j]: # Между узлами используем две соседние опоры.
fraction = (stamp - rt[j]) / (rt[j + 1] - rt[j]) # Доля положения внутри временного отрезка безразмерна.
target = target + fraction * (rv[j + 1] - target) # NaN любой опоры сохраняет разрыв интерполяции.
times.append(stamp) # Сохраняем общую временную сетку, включая пропуски.
expected.append(target) # Интерполированные амплитуды доступны для дальнейшего анализа.
if not math.isfinite(value) or not math.isfinite(target): # Некорректные значения любой кривой исключаются из показателей.
invalid += 1 # Пропуски уменьшают процент покрытия интервала.
differences.append(math.nan) # График остатка сохраняет разрыв на этом месте.
continue # Не загрязняем численные накопители значениями NaN.
difference = value - target # Знак ошибки всегда означает измерение минус эталон.
differences.append(difference) # Каждая корректная точка получает собственный остаток.
count += 1 # Число сравнений служит знаменателем средних показателей.
delta = difference - mean # Алгоритм Уэлфорда использует предыдущее среднее.
mean += delta / count # Обновляем среднее смещение без накопления большой суммы.
m2 += delta * (difference - mean) # Центральный момент устойчиво оценивает разброс остатка.
magnitude = abs(difference) # Модуль нужен для MAE, максимума и допуска.
mean_abs += (magnitude - mean_abs) / count # Потоково обновляем среднюю абсолютную ошибку.
mean_square += (difference * difference - mean_square) / count # Средний квадрат затем преобразуется в RMSE.
ref_min, ref_max = min(ref_min, target), max(ref_max, target) # Нормализация использует фактический размах эталона.
if magnitude > peak: # При равных максимумах оставляем время первого.
peak, peak_time = magnitude, stamp # Величина и время максимума относятся к одной точке.
if magnitude > absolute_tolerance + relative_tolerance_pct * .01 * abs(target): # Множитель 0,01 переводит процент в долю модуля эталона.
beyond += 1 # Равенство границе допустимо; учитываем строгое превышение.
if first_outside is None: # Время первого выхода не заменяется последующими.
first_outside = stamp # Сохраняем миллисекунды от начала измерения.
checkpoint(cancel) # Не публикуем результат, отменённый в последней порции.
if not count: # Без корректного перекрытия показатели не определены.
raise ValueError('Нет общих корректных отсчётов в выбранном интервале') # Вызывающая сторона сбрасывает старые показатели.
rmse = math.sqrt(max(0., mean_square)) # Защита от отрицательного округления перед извлечением корня.
metrics = dict(count=count, selected_points=candidates, outside_reference=outside, # Различаем использованные, выбранные и непокрытые точки.
invalid_points=invalid, coverage_pct=100. * count / candidates, # Покрытие выражается в процентах от выбранных точек.
bias=mean, mae=mean_abs, rmse=rmse, error_std=math.sqrt(max(0., m2 / count)), # СКО характеризует всю сравниваемую запись.
max_abs=peak, max_abs_time_ms=peak_time, # Максимум хранится в единицах канала, время — в миллисекундах.
nrmse_span_pct=100. * rmse / (ref_max - ref_min) if ref_max > ref_min else None, # Постоянный эталон не имеет ненулевого размаха для нормализации.
outside_tolerance=beyond, outside_tolerance_pct=100. * beyond / count, # Превышения считаются только среди корректных сравнений.
first_outside_time_ms=first_outside, # None явно означает отсутствие выхода за допуск.
absolute_tolerance=absolute_tolerance, relative_tolerance_pct=relative_tolerance_pct, # Сохраняем параметры коридора для отчёта.
align_start=align_start, shift_ms=shift_ms, interval_ms=interval, reference_min=ref_min, reference_max=ref_max) # Настройки и пределы сравниваемого эталона обеспечивают воспроизводимость анализа.
result = Comparison(Trace(measured.name, mt, measured.values), # Повторно используем полный массив амплитуд измерения.
Trace(reference.name, rt, reference.values), # Полный эталон доступен для наложения на график.
Trace('Измерение − эталон', times, differences), expected, metrics) # Остаток и показатели относятся ко всем корректным исходным точкам.
context = dict(channels, measured_origin_ms=origin, reference_origin_ms=ref_origin, shift_ms=shift_ms) if channels else None # Связанные каналы сохраняют взаимное положение относительно выбранного измерения и единого сдвига эталона.
result.analysis = analyze_comparison(result, cancel, circuit=circuit, channels=context) # Идентификация моделей использует тот же снимок данных и явно заданные электрические ограничения.
return result # Публикуем только целое сравнение вместе с завершёнными гипотезами.
+2 -2
View File
@@ -43,9 +43,9 @@ CRC_TAIL = 3
#: Границы настройки в GUI: меньше одного поля смысла нет, больше не бывает.
MIN_DIGIT_BYTES = 1
MAX_DIGIT_BYTES = 64
MAX_DIGIT_BYTES = 96 # Ответ 22220 содержит 70 дискретных байт; предел 96 оставляет запас в пределах буфера UART прошивки на 512 байт.
MIN_ANALOG_COUNT = 1
MAX_ANALOG_COUNT = 64
MAX_ANALOG_COUNT = 128 # Ответ 22220 содержит 80 знаковых слов; 128 слов вместе с дискретами помещаются в 512-байтовый буфер прошивки.
#: Номера команд, enum в конце RS_Functions.h.
COMMAND_NAMES: Dict[int, str] = {
+5
View File
@@ -0,0 +1,5 @@
"""Shared limits for host-side trend history buffers."""
DEFAULT_CAPACITY = 100_000
MAX_CAPACITY = 5_000_000
MAX_TOTAL_POINTS = 10_000_000
+50
View File
@@ -0,0 +1,50 @@
"""Explicit interpretation of ProtoCAN registers with the UMP 167/v1 map.
This is a manually selected dictionary, not detection of the legacy service
CAN packet format or a claim that a particular firmware implements ProtoCAN.
"""
from protocan import protocan as pc
from protocan.gas_167 import REGISTERS, describe_word
def decode_gas167(frame, node):
result = pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
result.device_label = "УМП %d (выбран вручную)" % node
result.protocol = "УМП %d / ProtoCAN GAS 167/v1" % node
if not frame.ide or frame.rtr or frame.is_error:
return result
if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE,
pc.MsgType.MODBUS_HOLDING):
return result
if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE:
start = result.id.body
else:
start, _count = pc.split_modbus(result.id.body)
words = result.registers
if words is None:
return result
labels = []
for address, value in words or [(start, None)]:
offset = address if 0 <= address < 128 else address - 0x5000
label = "0x%04X" % address
if 0 <= offset < len(REGISTERS):
entry = REGISTERS[offset]
label += " · " + entry['description']
result.fields += [
(" УМП 167/v1 0x%04X" % address, entry['description']),
(" Назначенные ID УМП 1/2", "%d / %d" % (entry['ump1_id'], entry['ump2_id'])),
(" Доступ", entry['access']),
]
else:
label += " · вне карты 167/v1"
if value is not None:
label += " = %d [0x%04X]" % (value, value)
description = describe_word(offset, value)
if description:
label += " — " + description
result.fields.append((" Биты 0x%04X" % address, description))
labels.append(label)
result.summary = "УМП %d · GAS 167/v1%s: %s" % (
node, " запрос" if not words else "", "; ".join(labels))
result.fields.append((" Карта", "167/v1 выбрана вручную"))
return result
+6 -6
View File
@@ -17,7 +17,7 @@ class Waveform:
vref: float
period_ms: float
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Именованный выбор решателя сохраняет прежние вызовы генератора.
"""One cyclic period, last endpoint excluded (no duplicated seam sample)."""
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
@@ -36,23 +36,23 @@ def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
raise ValueError("Период × частота должны давать от 2 до 4096 отсчётов")
if abs(exact_count - count) > 1e-7:
raise ValueError("Период должен содержать целое число отсчётов при выбранной частоте")
result = reconstruct(points, method, count, degree, endpoint=False)
result = reconstruct(points, method, count, degree, endpoint=False, backend=backend) # Формируем циклическую таблицу выбранным пользователем вариантом МНК.
# Check the endpoint too: polynomial fitting can move its value.
checked = reconstruct(points, method, count + 1, degree)
checked = reconstruct(points, method, count + 1, degree, backend=backend) # Конечную точку проверяем тем же решателем, исключая расхождение проверки диапазона ЦАП и результата.
dac12([p[1] for p in checked.points], vref)
codes = dac12([p[1] for p in result.points], vref)
return Waveform(tuple(result.points), codes, sample_rate, vref, period)
def recipe(points, sample_rate, vref, method, degree):
def recipe(points, sample_rate, vref, method, degree, *, backend="native"): # Новый необязательный параметр сохраняет совместимость ранее записанных рецептов версии 1.
return {"version": 1, "points_ms_volts": [list(pair) for pair in points], "sample_rate": sample_rate,
"vref": vref, "method": method, "degree": degree}
"vref": vref, "method": method, "degree": degree, "backend": backend} # Сохраняем выбранный решатель для воспроизводимого восстановления сигнала.
def load_recipe(text):
data = json.loads(text)
if not isinstance(data, dict) or data.get("version") != 1:
raise ValueError("Неизвестная версия задания генератора")
points = tuple((float(x), float(y)) for x, y in data["points_ms_volts"])
generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"])
generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"], backend=data.get("backend", "native")) # Старые рецепты без варианта используют прежний алгоритм, новые проверяют именно сохранённый решатель.
return points, data
def c_header(wave):
+242
View File
@@ -0,0 +1,242 @@
"""Двадцать синхронных заданий для двухканального ЦАП и векторного экрана XY.""" # Общая математика не зависит от Qt и используется предпросмотром и готовыми CSV.
import math # Аналитические траектории и несущие вычисляются в нормированных координатах.
from dataclasses import dataclass # Неизменяемый результат связывает описание, контуры и общую временную сетку.
SAMPLE_RATE = 40_000 # 40 кГц делит без остатка TIM6 72 МГц F407 и 160 МГц G474.
SAMPLE_COUNT = 1000 # Тысяча пар укладывается в аппаратный предел 4096 и даёт повторение 40 Гц при 40 кГц.
CAT_SAMPLE_RATE = 4_000 # 4000 пар/с воспроизводят 32 позы по 125 точек за одну секунду: 32 кадра/с и один шаговый цикл в секунду.
ANIMATION_SAMPLE_RATE = 4_000 # Полный геометрический цикл из 4000 пар длится одну секунду и использует точный делитель TIM6 обоих MCU.
ANIMATION_MODES = (("auto", "Движение фигуры"), ("twist", "Скручивание"), ("rotate", "Вращение"), ("pulse", "Пульсация"), ("none", "Статично")) # Параметр выбирает изменение координат всей фигуры, а не способ подсветки обходящего её луча.
WALKING_CAT = ("walking_cat", "Кот идёт · усы, лапы и хвост", "Полный цикл походки: 32 плавные позы, округлые щёки и спина, овальные глаза, четыре лапы, усы и изогнутый хвост.") # Дополнительный анимированный пример не заменяет десять основных XY-фигур.
BOAT_ON_WAVES = ("boat_waves", "Кораблик на волнах", "Кораблик, мачта, паруса и флажок неподвижны; два ряда волн движутся вправо под корпусом.") # Специальная сцена сохраняет неизменную геометрию кораблика во всех позах движения воды.
XY_EXTRA_EXAMPLES = (WALKING_CAT, BOAT_ON_WAVES) # Общий список дополнительных сцен синхронизирует каталог UI, готовые CSV и математическую модель.
DUAL_EXAMPLES = ( # Два канала используют общий период и воспроизводятся синхронно через PA4 и PA5.
("quadrature", "Синус и косинус · 90°", "Одинаковая частота, квадратурный сдвиг 90°; в XY получается окружность."), # Базовая проверка амплитуды, полярности и синхронности каналов.
("phase_120", "Два синуса · 120°", "Два синуса со сдвигом 120° для проверки фазовых измерений."), # Фазовый сдвиг воспроизводится без изменения временной сетки.
("harmonic_1_2", "Основная частота и вторая гармоника", "CH1 содержит один период, CH2 — два периода за общий кадр."), # Целое отношение частот замыкает оба сигнала на границе таблицы.
("beats_5_6", "Биения · 5:6", "Пять и шесть колебаний за кадр; сложение каналов показывает биения."), # Разность частот равна частоте повторения общего кадра.
("triangle_sine", "Треугольник и синус", "Два периода треугольника и синуса для сравнения формы и гармоник."), # Обе формы имеют одинаковые амплитуду и смещение.
("square_quadrature", "Два меандра · 90°", "Квадратурные прямоугольные сигналы с заполнением 50 %."), # Смещение на четверть периода удобно для проверки квадратурного счётчика.
("pwm_25_75", "Импульсы · заполнение 25 / 75 %", "Два синхронных импульсных сигнала с разной длительностью высокого уровня."), # Общий старт периода позволяет сравнивать заполнение на осциллографе.
("amplitude_modulation", "АМ-сигнал и огибающая", "CH1 — несущая с восемью колебаниями, CH2 — её медленная огибающая."), # Глубина модуляции не выводит сигнал за диапазон ЦАП.
("chirp_iq", "ЛЧМ · квадратура I/Q", "Частота растёт от 2 до 14 колебаний на кадр; второй канал сдвинут на 90°."), # Интеграл линейной частоты даёт восемь полных оборотов фазы за кадр.
("ring_down", "Затухающие колебания · два канала", "Восемь квадратурных колебаний с экспоненциальным затуханием; пакет повторяется."), # Переход через границу кадра является началом следующего пакета.
) # Стабильные ключи совпадают с именами готовых CSV.
XY_EXAMPLES = ( # Контуры рассчитаны для одинакового масштаба X и Y и входной связи DC.
("smiley", "Улыбка", "Лицо, глаза и улыбка; быстрые переходы между четырьмя контурами."), # Первый пример сохраняет прежний тест генератора.
("circle", "Окружность", "Один непрерывный круг — проверка квадратуры и масштаба каналов."), # Простейшая замкнутая траектория без переходов между деталями.
("heart", "Сердце", "Параметрическое сердце с плавным замкнутым контуром."), # Классическая сумма гармоник формирует узнаваемую фигуру.
("star", "Пятиконечная звезда", "Непрерывный контур звезды с пятью внешними и пятью внутренними вершинами."), # Луч проходит все стороны в порядке обхода контура.
("rose", "Лазерный цветок · 5 лепестков", "Полярная роза с пятью лепестками и общим центром."), # Повторяющиеся дуги напоминают узоры векторного лазерного шоу.
("lissajous", "Лиссажу · 3:2", "Замкнутая сетка из синусоид с отношением частот 3:2."), # Целые множители обеспечивают непрерывное периодическое замыкание.
("spirograph", "Спирограф · орбиты", "Гипотрохоида с восемью лепестками и переплетающимися орбитами."), # Разность основной и обратной гармоник создаёт тонкий симметричный узор.
("butterfly", "Бабочка", "Аналитическая кривая бабочки: симметричные крылья и волнистый край."), # Несколько оборотов параметра прорисовывают все детали замкнутой фигуры.
("saturn", "Сатурн", "Круглая планета и наклонное эллиптическое кольцо."), # Два контура сохраняются отдельно, чтобы предпросмотр показывал реальный переход.
("laser_tunnel", "Лазерный туннель", "Внешняя рамка неподвижна, внутренние скручиваются и раскручиваются в замкнутом цикле."), # Движение меняет относительные углы рамок, сохраняя центр и размер внешнего контура.
) # Ровно десять XY-примеров доступны в интерфейсе и на диске.
@dataclass(frozen=True) # Подготовленный кадр нельзя незаметно изменить после его выбора или экспорта.
class ExampleData: # Один результат содержит всё необходимое для просмотра и подготовки обоих выходов.
key: str # Постоянный идентификатор используется в CSV и каталоге примеров.
kind: str # Значение dual или xy определяет временной либо геометрический предпросмотр.
title: str # Русское название отображается в окне выбора и документации.
description: str # Описание объясняет форму, отношение частот и особенности обхода.
rate: int # Общая частота обоих каналов измеряется в парах отсчётов в секунду.
samples: tuple # Каждая строка содержит время в миллисекундах, X/CH1 и Y/CH2 в вольтах.
strokes: tuple # Контуры XY сохраняют границы деталей для честного отображения переходов луча.
frames: tuple = () # Анимация хранит отдельные позы, тогда как аппаратная таблица последовательно содержит их все.
animation: str = "none" # Название фактического движения связывает предпросмотр, аппаратные кадры и метаданные экспортированного примера.
def _curve(function, count): # Аналитическая траектория преобразуется в фиксированное число отсчётов одного периода.
return tuple(function(2 * math.pi * index / count) for index in range(count)) # Конечная точка периода не дублируется, замыкание происходит при повторении DMA-таблицы.
def _polygon(vertices, count): # Равномерное движение вдоль ломаной не зависит от разной длины её сторон.
lengths = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(vertices, vertices[1:])] # Длины сторон измеряются в нормированных координатах фигуры.
total, segment, origin = sum(lengths), 0, 0.0 # Суммарный путь и начало текущей стороны задают положение движущегося луча.
result = [] # Координаты сохраняются в порядке обхода, без скачков между соседними сторонами.
for index in range(count): # Аппаратный кадр получает ровно запрошенное число координат.
distance = total * index / count # Равный шаг по длине пути удерживает примерно одинаковую яркость прямых участков.
while segment < len(lengths) - 1 and distance > origin + lengths[segment]: # Переходим к следующей стороне после достижения её начала.
origin += lengths[segment] # Пройденная длина предыдущей стороны исключается из локальной координаты.
segment += 1 # Последующая интерполяция использует следующую пару вершин.
fraction = (distance - origin) / lengths[segment] # Доля текущей стороны лежит между её началом и концом.
a, b = vertices[segment:segment + 2] # Две соседние вершины определяют единственный прямой участок.
result.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная интерполяция не добавляет закругления к углам геометрической фигуры.
return tuple(result) # Готовая последовательность подходит для периодического воспроизведения и предпросмотра.
def smiley_strokes(vref): # Прежний рисунок сохраняет четыре контура и ровно 1000 синхронных отсчётов.
if not math.isfinite(vref) or not 0 < vref <= 3.6: # Предел опорного напряжения совпадает с контрактом 12-битного ЦАП.
raise ValueError("Vref должен быть в диапазоне 0…3,6 В") # Недопустимая шкала отвергается до расчёта таблицы.
contours = ((0, 0, .85, 0, 2 * math.pi, 500), (-.27, .25, .055, 0, 2 * math.pi, 80), (.27, .25, .055, 0, 2 * math.pi, 80), (0, .10, .50, 7 * math.pi / 6, 11 * math.pi / 6, 340)) # Лицо, два глаза и нижняя дуга улыбки сохраняют прежние размеры и порядок.
return tuple(tuple(((1 + cx + radius * math.cos(start + (stop - start) * i / (count - 1))) * vref / 2, (1 + cy + radius * math.sin(start + (stop - start) * i / (count - 1))) * vref / 2) for i in range(count)) for cx, cy, radius, start, stop, count in contours) # Обе конечные точки дуг включены, а отступ лица от шин питания составляет 7,5 % Vref.
def _xy_strokes(key): # Нормированные траектории отделены от электрического масштаба и поворота рисунка.
if key == "smiley": # Совместимость старого теста важна для экспорта и настройки осциллографа.
return tuple(tuple((x - 1, y - 1) for x, y in stroke) for stroke in smiley_strokes(2.0)) # При Vref 2 В вычитание единицы восстанавливает исходные координаты вокруг нуля.
functions = { # Каждый аналитический контур укладывается в один непрерывный аппаратный период.
"circle": lambda a: (math.cos(a), math.sin(a)), # Одинаковые амплитуды синуса и косинуса дают точный круг.
"heart": lambda a: (16 * math.sin(a) ** 3, 13 * math.cos(a) - 5 * math.cos(2 * a) - 2 * math.cos(3 * a) - math.cos(4 * a)), # Классическая параметризация сердца использует безразмерные коэффициенты гармоник.
"rose": lambda a: (math.cos(5 * a) * math.cos(a), math.cos(5 * a) * math.sin(a)), # Полярный радиус cos(5a) создаёт пять симметричных лепестков.
"lissajous": lambda a: (math.sin(3 * a + math.pi / 2), math.sin(2 * a)), # Частоты 3 и 2 колебания за период формируют замкнутую фигуру Лиссажу.
"spirograph": lambda a: (.65 * math.cos(a) + .35 * math.cos(7 * a), .65 * math.sin(a) - .35 * math.sin(7 * a)), # Противоположное вращение седьмой гармоники создаёт восемь радиальных лепестков.
"butterfly": lambda a: (math.sin(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5), math.cos(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5)), # Полный период аналитической бабочки составляет 24π по параметру a.
} # Геометрическое масштабирование ниже оставляет запас до нуля и Vref при любом повороте.
if key in functions: # Большинство фигур формируются одним аналитическим контуром.
function = functions[key] # Выбор по ключу не зависит от перевода отображаемого названия.
strokes = (_curve((lambda a: function(12 * a)) if key == "butterfly" else function, SAMPLE_COUNT),) # Двенадцать оборотов замыкают полный период бабочки; остальные кривые обходятся за 2π.
elif key == "star": # Чередование двух радиусов формирует выпуклые и внутренние вершины звезды.
vertices = tuple(((1 if i % 2 == 0 else .42) * math.cos(math.pi / 2 + i * math.pi / 5), (1 if i % 2 == 0 else .42) * math.sin(math.pi / 2 + i * math.pi / 5)) for i in range(10)) # Внешние вершины смотрят вверх; внутренний радиус равен 42 % внешнего.
strokes = (_polygon(vertices + vertices[:1], SAMPLE_COUNT),) # Дополнительная первая вершина задаёт последнюю замыкающую сторону.
elif key == "saturn": # Планета и кольцо требуют двух отдельных обходов.
strokes = (_curve(lambda a: (.54 * math.cos(a), .54 * math.sin(a)), 500), _curve(lambda a: (math.cos(a), .28 * math.sin(a) + .30 * math.cos(a)), 500)) # Равные бюджеты отсчётов дают круг и наклонное эллиптическое кольцо.
elif key == "laser_tunnel": # Четыре вложенных рамки создают перспективную композицию.
strokes = tuple(_polygon(tuple((radius * math.cos(math.pi / 4 + i * math.pi / 2 + turn), radius * math.sin(math.pi / 4 + i * math.pi / 2 + turn)) for i in range(5)), 250) for radius, turn in ((1, 0), (.72, .12), (.45, .24), (.20, .36))) # По 250 отсчётов на рамку сохраняют общий размер 1000 пар и постепенный поворот вложенных квадратов.
else: # Неизвестный ключ не должен незаметно подменяться случайной фигурой.
raise ValueError("Неизвестный XY-пример: " + key) # Ошибка данных каталога выявляется до подготовки аппаратного задания.
points = [point for stroke in strokes for point in stroke] # Общий центр и масштаб вычисляются по всем деталям рисунка.
cx = (min(x for x, _ in points) + max(x for x, _ in points)) / 2 # Центр ограничивающего прямоугольника убирает постоянное смещение сложной фигуры.
cy = (min(y for _, y in points) + max(y for _, y in points)) / 2 # Вертикальное центрирование сохраняет запас до обеих шин ЦАП.
radius = max(math.hypot(x - cx, y - cy) for x, y in points) # Ограничение радиуса сохраняет диапазон напряжений при любом угле поворота.
return tuple(tuple((.85 * (x - cx) / radius, .85 * (y - cy) / radius) for x, y in stroke) for stroke in strokes) # Радиус 0,85 оставляет минимум 7,5 % Vref с каждой стороны при полном масштабе.
def _dual_point(key, t): # Значение t задаёт долю общего повторяющегося периода двух каналов.
a = 2 * math.pi * t # Основная фаза за один аппаратный кадр проходит полный оборот.
functions = { # Нормированные амплитуды всех форм не превышают единицу по модулю.
"quadrature": lambda: (math.sin(a), math.cos(a)), # Квадратурная пара имеет одинаковые частоты и сдвиг 90°.
"phase_120": lambda: (math.sin(a), math.sin(a + 2 * math.pi / 3)), # Сдвиг 2π/3 соответствует 120 электрическим градусам.
"harmonic_1_2": lambda: (math.sin(a), math.sin(2 * a)), # Вторая координата содержит вторую гармонику первой.
"beats_5_6": lambda: (math.sin(5 * a), math.sin(6 * a)), # Разность частот составляет одно колебание за общий кадр.
"triangle_sine": lambda: (1 - 4 * abs((2 * t) % 1 - .5), math.sin(2 * a)), # Треугольник и синус совершают по два колебания за кадр.
"square_quadrature": lambda: (1 if t < .5 else -1, 1 if (t + .25) % 1 < .5 else -1), # Два меандра заполнения 50 % смещены на четверть периода.
"pwm_25_75": lambda: (1 if t < .25 else -1, 1 if t < .75 else -1), # Длительности высоких уровней составляют 25 и 75 % периода.
"amplitude_modulation": lambda: ((.6 + .4 * math.sin(a)) * math.sin(8 * a), .6 + .4 * math.sin(a)), # Второй канал совпадает с верхней огибающей восьмипериодной несущей относительно общего смещения Vref/2.
"chirp_iq": lambda: (math.sin(2 * math.pi * (2 * t + 6 * t * t)), math.cos(2 * math.pi * (2 * t + 6 * t * t))), # Производная фазы задаёт линейный рост частоты от 2 до 14 циклов на кадр.
"ring_down": lambda: (math.exp(-4 * t) * math.sin(8 * a), math.exp(-4 * t) * math.cos(8 * a)), # За кадр амплитуда уменьшается в e⁴ раз, после чего начинается следующий пакет.
} # Каждый ключ каталога имеет самостоятельную аналитическую форму пары.
return tuple(.85 * value for value in functions[key]()) # Ограничение амплитуды 85 % полудиапазона оставляет запас 7,5 % Vref до шин питания.
def _cat_bezier(start, first, second, end, steps): # Кубическая кривая возвращает только новые вершины; общий конец соседних дуг не дублируется.
return tuple(((1-t)**3*start[0]+3*(1-t)**2*t*first[0]+3*(1-t)*t*t*second[0]+t**3*end[0], (1-t)**3*start[1]+3*(1-t)**2*t*first[1]+3*(1-t)*t*t*second[1]+t**3*end[1]) for t in (i/steps for i in range(1,steps+1))) # Формула Бернштейна сохраняет гладкие щёки, спину и лапы внутри выпуклой оболочки контрольных точек.
def cat_strokes(phase): # Одна поза содержит 125 точек плавного силуэта; 32 позы сохраняют прежнюю аппаратную таблицу из 4000 пар.
angle=2*math.pi*phase; bob=.018*math.cos(2*angle) # Один замкнутый шаговый цикл задаёт плавное движение диагональных лап и небольшое покачивание корпуса.
body=((.26,.38),) # Начинаем у загривка: плечи, спина, круп и живот образуют органичный контур вместо правильного овала.
for first,second,end,steps in (((.08,.52),(-.30,.48),(-.44,.40),4),((-.60,.39),(-.67,.25),(-.60,.13),4),((-.53,-.06),(-.40,-.13),(-.20,-.06),4),((.12,-.11),(.28,.02),(.27,.20),5),((.31,.30),(.29,.35),(.26,.38),3)): # Пять дуг распределяют 20 новых точек между спиной, крупом, животом, грудью и шеей.
body+=_cat_bezier(body[-1],first,second,end,steps) # Касательные на соседних дугах меняются плавно и замыкают туловище за 21 точку.
head=((.20,.43),)+_cat_bezier((.20,.43),(.12,.55),(.17,.69),(.26,.72),4)+((.26,.90),(.39,.76)) # Левая щека округлена, а две вершины сохраняют естественный острый кончик левого уха.
head+=_cat_bezier(head[-1],(.45,.80),(.55,.80),(.61,.75),4)+((.78,.87),(.75,.63)) # Плавный лоб соединяет два уха; правое ухо имеет отдельные кончик и наружное основание.
head+=_cat_bezier(head[-1],(.89,.52),(.89,.36),(.74,.29),6)+_cat_bezier((.74,.29),(.63,.18),(.39,.17),(.26,.30),6)+_cat_bezier((.26,.30),(.21,.34),(.18,.38),(.20,.43),4) # Щёки и подбородок замыкаются округлой мордочкой; всего 29 точек вместо прежнего угловатого многоугольника.
tail=((-.54,.29),)+_cat_bezier((-.54,.29),(-.77,.30),(-.99,.61),(-.84,.83),5)+_cat_bezier((-.84,.83),(-.74,1.00),(-.48,.98),(-.56,.78),6) # Две плавные дуги образуют приподнятый хвост с округлым загнутым кончиком, всего 12 координат.
tail=tuple((x+.05*math.sin(angle+.8*i/11)*(i/11)**2,y+bob+.02*math.cos(angle+i/11)*i/11) for i,(x,y) in enumerate(tail)) # Покачивание усиливается к концу хвоста; основание следует за корпусом без разрывов.
legs=[] # Каждая лапа сохраняет собственный контур и фазу, чтобы передняя и дальняя пары не слипались.
for root,offset in ((-.45,0),(-.28,math.pi),(.07,math.pi),(.20,0)): # Диагональные пары чередуют опору и перенос, сохраняя узнаваемую походку.
stride=.14*math.sin(angle+offset); foot_y=-.41+.10*max(0,math.cos(angle+offset))**2 # Во время переноса лапа идёт вперёд и поднимается по гладкой дуге; у земли вертикальная скорость равна нулю.
start=(root,.07+bob); ankle=(root+stride,foot_y+.015) # Корень спрятан внутри туловища, голень заканчивается немного выше подушечки лапы.
legs.append((start,)+_cat_bezier(start,(root+stride*.18,-.04),(root+stride*.25,foot_y+.09),ankle,4)+((ankle[0]+.045,foot_y-.015),(ankle[0]+.09,foot_y+.005))) # Семь точек сглаживают колено/голень и добавляют округлый носок вместо прямоугольного окончания.
whiskers=tuple(((.62 if side>0 else .49,.41+bob),(x,y+bob)) for side,x,y in ((1,.97,.54),(1,.99,.41),(1,.96,.29),(-1,.24,.54),(-1,.22,.41),(-1,.25,.29))) # Шесть независимых усов по две точки остаются хорошо различимыми по сторонам мордочки.
eyes=tuple(tuple((cx+.045*math.cos(2*math.pi*i/7),.54+bob+.035*math.sin(2*math.pi*i/7)) for i in range(8)) for cx in (.40,.66)) # Два небольших замкнутых овальных глаза получают по восемь точек вместо двух коротких прямых.
nose=(((.50,.43+bob),(.61,.43+bob),(.555,.38+bob),(.50,.43+bob)),) # Маленький треугольный нос занимает четыре координаты между глазами и усами.
mouth=(((.48,.35+bob),(.555,.30+bob),(.63,.35+bob)),) # Короткая улыбка делает мордочку узнаваемой, не увеличивая аппаратный бюджет фигуры.
strokes=(tuple((x,y+bob) for x,y in body),tuple((x,y+bob) for x,y in head),tail,*legs,*whiskers,*eyes,*nose,*mouth) # Сохраняем порядок деталей: тело/голова/хвост, четыре лапы, шесть усов, глаза, нос и рот.
return tuple(tuple((.72*x,.72*y) for x,y in stroke) for stroke in strokes) # Общий масштаб 0,72 удерживает новый округлый контур и хвост в допустимом диапазоне при любом повороте XY.
def boat_strokes(phase): # Один кадр содержит 30 неподвижных точек кораблика и 50 точек двух движущихся рядов волн.
hull = ((-.56, -.05), (-.20, -.05), (.20, -.05), (.58, -.05), (.42, -.26), (0, -.29), (-.36, -.26), (-.56, -.05)) # Восемь вершин замыкают корпус с поднятыми бортами и выделенным днищем без зависимости от фазы волн.
mast = ((0, -.05), (0, .72)) # Мачта закреплена на палубе и остаётся вертикальной во всех кадрах сцены.
main_sail = ((-.035, .65), (-.035, .34), (-.035, .03), (-.47, .03), (-.25, .34), (-.035, .65)) # Шесть координат левого треугольного паруса сохраняют его полный замкнутый контур.
fore_sail = ((.035, .55), (.25, .30), (.47, .03), (.25, .03), (.035, .03), (.035, .55)) # Шесть координат переднего паруса создают второй узнаваемый треугольник без поворота или качания.
pennant = ((0, .72), (.21, .65), (0, .58), (0, .72)) # Четыре точки замыкают неподвижный флажок на вершине мачты.
stripe = ((-.43, -.13), (-.15, -.16), (.20, -.16), (.46, -.13)) # Полоса на борту подчёркивает форму корпуса и также не зависит от движения воды.
upper_wave, lower_wave = [], [] # Два самостоятельных контура воды движутся с одной скоростью и имеют разные высоты и фазы.
for index in range(25): # По 25 точек на ряд дают 50 координат воды и общий бюджет ровно 80 пар на кадр.
x = -.85 + 1.70 * index / 24 # Фиксированное горизонтальное окно содержит обе границы воды и два полных периода волны.
angle = 2 * math.pi * (2 * index / 24 - phase) # Убывание фазы при фиксированном X перемещает гребни вправо, а полный цикл возвращает исходную воду.
upper_wave.append((x, -.36 + .05 * math.sin(angle) + .012 * math.sin(2 * angle))) # Небольшая вторая гармоника делает гребни выразительнее; верхняя вода остаётся ниже неподвижного днища.
lower_wave.append((x, -.55 + .04 * math.sin(angle + .8))) # Нижняя волна сдвинута на 0,8 радиана и создаёт глубину воды без изменения положения кораблика.
return tuple(tuple((.78 * x, .78 * y) for x, y in stroke) for stroke in (hull, mast, main_sail, fore_sail, pennant, stripe, upper_wave, lower_wave)) # Общий коэффициент 0,78 удерживает даже крайние точки воды в допустимом радиусе при любом пользовательском повороте сцены.
def _resample_strokes(strokes, count): # Аппаратный лимит делится между позами без удаления отдельных деталей фигуры.
total = sum(map(len, strokes)) # Доли исходных контуров определяют бюджет точек для каждой видимой детали.
budgets = [max(2, round(count * len(stroke) / total)) for stroke in strokes] # Минимум две точки сохраняет даже короткий отдельный контур.
largest = max(range(len(strokes)), key=lambda index: len(strokes[index])) # Коррекция округления применяется к самому длинному контуру с достаточным запасом точек.
budgets[largest] += count - sum(budgets) # Каждый геометрический кадр получает ровно согласованное число пар.
result = [] # Контуры остаются отдельными и сохраняют прежний порядок аппаратного обхода.
for stroke, budget in zip(strokes, budgets): # Уменьшаем только плотность точек внутри одной детали, не соединяя разные контуры.
closed = stroke[0] == stroke[-1] # Явно замкнутая кривая должна сохранить совпадение первой и последней координат.
samples = [] # Новые опорные точки распределяются вдоль исходного параметрического порядка.
for index in range(budget): # Бюджет фиксирует одинаковую длительность всех поз анимации.
position = index * (len(stroke) - 1) / (budget - 1) if closed else index * len(stroke) / budget # Замкнутые контуры включают оба конца, а периодические без дубликата учитывают последний переход к началу.
source = int(position) # Целая часть выбирает исходный линейный участок фигуры.
a, b = stroke[source], stroke[(source + 1) % len(stroke)] # Циклический сосед сохраняет замыкание параметрических контуров.
fraction = position - source # Дробная часть задаёт положение между двумя исходными вершинами.
samples.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная выборка не создаёт выход за диапазон исходной геометрии.
result.append(tuple(samples)) # Границы отдельных деталей сохраняются для предпросмотра и аппаратной пары.
return tuple(result) # Один компактный кадр пригоден для многократных геометрических преобразований.
def _square_stroke(radius, angle): # Компактная рамка сохраняет все четыре точных угла и полное замыкание при бюджете 20 пар.
vertices = tuple((radius * math.cos(angle + index * math.pi / 2), radius * math.sin(angle + index * math.pi / 2)) for index in range(4)) # Четыре вершины лежат на окружности заданного нормированного радиуса с шагом 90 градусов.
points = [] # Отдельные стороны включают свои конечные вершины до перехода к следующему контуру.
for side in range(4): # Каждая сторона получает одинаковую длительность аппаратного обхода.
a, b = vertices[side], vertices[(side + 1) % 4] # Последняя сторона возвращается точно к исходной вершине квадрата.
for sample in range(5): # Пять точек на сторону, включая оба конца, дают ровно 20 пар на рамку.
fraction = sample / 4 # Четыре равных интервала покрывают всю сторону, сохраняя последние 20 % контура перед переходом к другой детали.
points.append(b if sample == 4 else (a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Явная конечная вершина замыкает квадрат без разрыва или потери угла из-за округления.
return tuple(points) # Первая и последняя координаты совпадают, поэтому яркий контур целиком виден в каждой позе.
def _figure_frames(key, motion): # Создаёт замкнутый цикл изменения самой геометрии и общий бюджет не более 4000 пар.
if key == "laser_tunnel" and motion == "twist": # Для туннеля важны неподвижная внешняя рамка и разные углы вложенных квадратов.
frames = [] # Пятьдесят поз дают плавное скручивание и обратный ход за один аппаратный период.
for index in range(50): # По 80 точек на позу сохраняют полный объём 4000 пар.
twist = 1.8 * (1 - math.cos(2 * math.pi * index / 50)) / 2 # Угол от 0 до 1,8 радиана и обратно меняется плавно, без разрыва скорости на границе периода.
frames.append(tuple(_square_stroke(radius, math.pi / 4 + twist * depth / 3) for depth, radius in enumerate((.85, .612, .3825, .17)))) # Глубина 0 фиксирует внешнюю рамку; по 20 пар сохраняют все углы и полностью замкнутые стороны каждого вложенного квадрата.
return tuple(frames) # Одинаковые контуры и индексы вершин позволяют проверить обратимость геометрического движения.
count, frame_count = (200, 20) if key == "butterfly" else (100, 40) # Сложной бабочке оставляем 200 точек на позу; остальным фигурам достаточно 100 при 40 кадрах/с.
original = _resample_strokes(_xy_strokes(key), count) # Исходная форма подготавливается один раз, а не заново на каждом тике GUI.
frames = [] # Весь цикл хранится в неизменяемом задании и попадает в CSV целиком.
for index in range(frame_count): # Последняя поза не дублирует первую; возврат замыкается следующим аппаратным периодом.
phase = 2 * math.pi * index / frame_count # Полный оборот фазы соответствует одному геометрическому циклу.
deformation = (1 - math.cos(phase)) / 2 # Плавный ход 0…1…0 обеспечивает скручивание и последующее раскручивание.
strokes = [] # Каждая поза сохраняет исходный набор деталей и их порядок.
for stroke in original: # Деформация применяется ко всем координатам фигуры вокруг общего центра.
points = [] # Точки одной детали преобразуются независимо от частоты обхода контура.
for x, y in stroke: # Нормированные координаты находятся внутри радиуса 0,85 до электрического масштабирования.
angle = phase if motion == "rotate" else 2.4 * deformation * max(0, 1 - math.hypot(x, y) / .85) if motion == "twist" else 0 # Скручивание усиливается к центру, а обычное вращение сохраняет относительную форму.
scale_x = 1 - .35 * deformation if motion in ("pulse", "breathe") else 1 # Пульсация плавно уменьшает ширину фигуры, сохраняя запас до шин ЦАП.
scale_y = 1 - (.15 if motion == "breathe" else .35) * deformation if motion in ("pulse", "breathe") else 1 # Разные масштабы дыхания превращают окружность в эллипс, чтобы движение было видно у симметричной фигуры.
points.append((scale_x * (x * math.cos(angle) - y * math.sin(angle)), scale_y * (x * math.sin(angle) + y * math.cos(angle)))) # Поворот и сжатие сохраняют допустимый радиус при любой фазе цикла.
strokes.append(tuple(points)) # Полный контур одной позы виден одновременно, без бегущего светового хвоста.
frames.append(tuple(strokes)) # Все детали текущей позы сохраняются перед переходом к следующей фазе движения.
return tuple(frames) # Размер каждого цикла равен 4000 пар и укладывается в аппаратный предел 4096.
def generate_example(kind, key, vref=3.3, rate=None, amplitude=1.0, rotation=0.0, animation="none"): # Параметр анимации выбирает полный аппаратный цикл движения фигуры; статический API остаётся совместимым с прежними заданиями.
if kind not in ("dual", "xy"): # Список режимов ограничен двухканальными временными сигналами и геометрией XY.
raise ValueError("Неизвестный тип примера") # Ошибка вызывающего кода не должна выбирать другой каталог.
catalogue = DUAL_EXAMPLES if kind == "dual" else XY_EXAMPLES + XY_EXTRA_EXAMPLES # Кот и кораблик добавляются после десяти основных фигур без подмены прежних примеров.
metadata = next((entry for entry in catalogue if entry[0] == key), None) # Постоянный ключ определяет русское имя и описание выбранного примера.
if metadata is None: # Неизвестные или перепутанные ключи явно отвергаются.
raise ValueError("Неизвестный пример: " + key) # Сообщение содержит конкретный отсутствующий идентификатор.
if animation not in dict(ANIMATION_MODES): # Явная проверка исключает незаметную подмену опечатанного режима статической картинкой.
raise ValueError("Неизвестная анимация фигуры: " + animation) # Ошибка выявляется до создания и загрузки частичной таблицы.
motion = ({"circle": "breathe", "heart": "pulse", "laser_tunnel": "twist", "butterfly": "pulse"}.get(key, "rotate") if animation == "auto" else animation) if kind == "xy" else "none" # Режим по фигуре выбирает заметное движение даже для вращательно-симметричной окружности.
motion = {"walking_cat": "walk", "boat_waves": "waves"}.get(key, motion) # Специальные сцены сохраняют заданное движение: у кота работают лапы и хвост, а у кораблика движется исключительно вода.
if rate is None: # Частота по умолчанию зависит от полного размера анимированного цикла.
rate = ANIMATION_SAMPLE_RATE if motion != "none" else SAMPLE_RATE # Любой полный цикл из 4000 пар длится одну секунду; статический контур сохраняет прежние 40 кГц.
if not math.isfinite(vref) or not 0 < vref <= 3.6 or not math.isfinite(amplitude) or not 0 < amplitude <= 1 or not math.isfinite(rotation): # Положительный масштаб до 100 % и Vref до 3,6 В сохраняют электрический диапазон обоих выходов.
raise ValueError("Проверьте Vref, масштаб 0…100 % и угол поворота") # Некорректные параметры не формируют частично пригодный кадр.
if type(rate) is not int or not 1 <= rate <= 1_000_000: # Частота ограничена тем же пределом 1 Мвыб/с, что основной генератор.
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц") # Аппаратный профиль дополнительно проверяется при переносе в редактор.
if key in ("walking_cat", "boat_waves"): # Специальная анимация строится напрямую из неизменных и движущихся деталей сцены.
frame_generator = cat_strokes if key == "walking_cat" else boat_strokes # Выбор функции не смешивает общий поворот фигуры с движением её отдельных элементов.
frame_count = 32 if key == "walking_cat" else 50 # Коту нужны 125 точек на плавный силуэт, кораблик сохраняет прежние 50 поз по 80 точек.
frames = tuple(frame_generator(index / frame_count) for index in range(frame_count)) # Оба специальных примера сохраняют ровно 4000 пар, одну секунду цикла и аппаратный предел 4096.
else: # Обычные геометрические фигуры продолжают использовать существующие деформации и статичный режим.
frames = _figure_frames(key, motion) if motion != "none" else () # Предыдущие контуры сохраняют свой бюджет и поведение без изменений.
normalized = tuple(stroke for frame in frames for stroke in frame) if frames else _xy_strokes(key) if kind == "xy" else (tuple(_dual_point(key, i / SAMPLE_COUNT) for i in range(SAMPLE_COUNT)),) # Аппаратный кадр кота последовательно содержит все позы, а обычные примеры сохраняют 1000 пар.
angle = math.radians(rotation if kind == "xy" else 0) # Поворот изменяет только геометрические фигуры и не смешивает диагностические временные сигналы.
cosine, sine = math.cos(angle), math.sin(angle) # Тригонометрия рассчитывается один раз для всего кадра, а не для каждого отсчёта.
strokes = tuple(tuple(((1 + amplitude * (x * cosine - y * sine)) * vref / 2, (1 + amplitude * (x * sine + y * cosine)) * vref / 2) for x, y in stroke) for stroke in normalized) # Однополярные выходы центрируются на Vref/2, а XY поворачивается вокруг этого центра.
samples = tuple((index * 1000 / rate, x, y) for index, (x, y) in enumerate(point for stroke in strokes for point in stroke)) # Обе координаты каждого индекса имеют строго одно время в миллисекундах.
frame_size = len(frames[0]) if frames else 0 # Любая геометрическая анимация сохраняет одинаковое число контуров и пар во всех позах.
display_frames = tuple(strokes[index:index + frame_size] for index in range(0, len(strokes), frame_size)) if frames else () # Преобразованные позы сохраняют точные напряжения той же аппаратной последовательности.
return ExampleData(key, kind, metadata[1], metadata[2], rate, samples, display_frames[0] if frames else strokes, display_frames, motion) # Предпросмотр, экспорт и аппаратная пара получают одно движение и все его кадры.
+12
View File
@@ -0,0 +1,12 @@
from dataclasses import dataclass, fields # Неизменяемая таблица наследует прежнюю модель, сохраняя сравнение обеих координат перед пуском.
from .waveform import Waveform # Общие частота, точки X, Vref и период принадлежат прежней модели генератора.
from .signal_reconstruction import dac12 # Напряжения Y квантует то же C-ядро, что используется одиночным выходом X.
@dataclass(frozen=True) # Изменение любого кода Y создаёт другое задание и требует повторной загрузки перед start.
class XYWaveform(Waveform): # Дополняет один общий период второй синхронной координатой без дублирования настройки таймера.
codes_y: tuple # Ровно один 12-битный код Y соответствует каждому коду X с тем же индексом и временем.
def with_y(wave, volts): # Добавляет вертикальное отклонение к уже построенной и проверенной временной сетке X.
values=tuple(volts) # Однократно фиксирует последовательность, включая генераторы Python.
if len(values)!=len(wave.codes): # Оба DMA-канала должны содержать строго одинаковое число пар, без дополнения и обрезки.
raise ValueError("X и Y должны иметь одинаковое число отсчётов") # Ошибка выявляется до открытия порта и изменения состояния устройства.
codes=dac12(values,wave.vref) # Общая проверка отвергает NaN, бесконечность и выход Y за диапазон 0…Vref.
return XYWaveform(**{field.name:getattr(wave,field.name) for field in fields(Waveform)},codes_y=codes) # Сохраняет точные коды X и добавляет Y без интерполяции и изменения частоты.