Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
420
python/set_devices/spectrum_stream.py
Normal file
420
python/set_devices/spectrum_stream.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""@file spectrum_stream.py
|
||||
@brief Разбор потока спектра и гармоник от прибора на STM32.
|
||||
|
||||
Прибор снимает сигнал вибродатчика или микрофона, считает быстрое
|
||||
преобразование Фурье прямо на месте и передаёт готовый результат: сам спектр
|
||||
и параметры найденных гармоник. Задача этого модуля — превратить поток байтов
|
||||
в структуры, с которыми работает вкладка.
|
||||
|
||||
Модуль не зависит от Qt и от последовательного порта: на вход подаются байты,
|
||||
на выход идут кадры. Так его можно проверить тестами без прибора и без окна.
|
||||
|
||||
Формат строки повторяет привычный по навигационным приборам вид: доллар,
|
||||
поля через запятую, звёздочка и контрольная сумма::
|
||||
|
||||
$SPEC,<seq>,<fs>,<fft>,<bins>,<top>,<range>,<hex>*<cs>
|
||||
$HARM,<seq>,<f0>,<a0>,<thd>,<snr>,<rms>,<n>[,<f>,<a>,<db>]...*<cs>
|
||||
|
||||
Текстовый формат выбран намеренно, хотя двоичный вышел бы компактнее.
|
||||
Прибор отлаживается через обычный терминал, и возможность увидеть поток
|
||||
глазами дороже экономии канала. Спектр при этом ужат до одного байта
|
||||
на элемент, поэтому кадр укладывается примерно в 290 байт и на скорости
|
||||
115200 бод спокойно идёт пять раз в секунду.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Начальный символ строки.
|
||||
LINE_START = "$"
|
||||
|
||||
#: Разделитель контрольной суммы.
|
||||
CHECKSUM_MARK = "*"
|
||||
|
||||
#: Тип строки со спектром.
|
||||
TAG_SPECTRUM = "SPEC"
|
||||
|
||||
#: Тип строки с гармониками.
|
||||
TAG_HARMONICS = "HARM"
|
||||
|
||||
#: Наибольшее число гармоник в кадре; совпадает с пределом прошивки.
|
||||
MAX_HARMONICS = 8
|
||||
|
||||
#: Наибольшая длина строки. Защита от мусора в канале: без неё обрыв связи
|
||||
#: посреди передачи привёл бы к бесконечному накоплению в буфере.
|
||||
MAX_LINE_LENGTH = 4096
|
||||
|
||||
|
||||
def checksum(payload: str) -> int:
|
||||
"""@brief Считает контрольную сумму строки.
|
||||
|
||||
@param payload Содержимое между ``$`` и ``*``.
|
||||
@return Побайтовое исключающее ИЛИ, 0..255.
|
||||
|
||||
Сумма именно такая простая не от небрежности: канал короткий и
|
||||
проводной, а задача суммы здесь — отсеять строку, склеенную из двух
|
||||
обрывков, а не защитить от искажения одного бита.
|
||||
"""
|
||||
value = 0
|
||||
for char in payload.encode("ascii", errors="replace"):
|
||||
value ^= char
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Harmonic:
|
||||
"""@brief Одна найденная гармоника."""
|
||||
|
||||
index: int
|
||||
"""Номер гармоники: 2 — вторая, 3 — третья и так далее."""
|
||||
|
||||
frequency_hz: float
|
||||
"""Измеренная частота."""
|
||||
|
||||
amplitude_mv: float
|
||||
"""Амплитуда."""
|
||||
|
||||
relative_db: float
|
||||
"""Уровень относительно основного тона, децибелы. Всегда отрицателен
|
||||
у исправного тракта: гармоника слабее основного тона."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarmonicsFrame:
|
||||
"""@brief Параметры основного тона и его гармоник."""
|
||||
|
||||
seq: int
|
||||
fundamental_hz: float
|
||||
fundamental_mv: float
|
||||
thd_percent: float
|
||||
"""Коэффициент гармонических искажений."""
|
||||
snr_db: float
|
||||
rms_mv: float
|
||||
harmonics: tuple[Harmonic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectrumFrame:
|
||||
"""@brief Спектр целиком."""
|
||||
|
||||
seq: int
|
||||
sample_rate_hz: int
|
||||
fft_size: int
|
||||
top_dbmv: float
|
||||
"""Верхняя граница шкалы уровней, децибелы относительно милливольта."""
|
||||
range_db: float
|
||||
"""Полный размах шкалы уровней."""
|
||||
levels_db: tuple[float, ...] = field(default=())
|
||||
"""Уровни элементов спектра в децибелах относительно милливольта."""
|
||||
|
||||
@property
|
||||
def bin_width_hz(self) -> float:
|
||||
"""@brief Шаг по частоте между элементами переданного спектра.
|
||||
|
||||
Учитывает, что прибор мог проредить спектр перед передачей: делится
|
||||
не на размер преобразования, а на фактическое число элементов.
|
||||
"""
|
||||
if not self.levels_db:
|
||||
return 0.0
|
||||
top_frequency = self.sample_rate_hz / 2.0
|
||||
return top_frequency / len(self.levels_db)
|
||||
|
||||
def frequency_of(self, index: int) -> float:
|
||||
"""@brief Частота, соответствующая элементу спектра.
|
||||
|
||||
@param index Номер элемента.
|
||||
@return Частота в герцах.
|
||||
"""
|
||||
return index * self.bin_width_hz
|
||||
|
||||
|
||||
class SpectrumStreamError(Exception):
|
||||
"""@brief Строка не разобрана. Несёт причину для журнала."""
|
||||
|
||||
|
||||
def _split_line(line: str) -> tuple[str, list[str]]:
|
||||
"""@brief Проверяет обрамление и контрольную сумму, возвращает поля.
|
||||
|
||||
@param line Одна строка без перевода строки.
|
||||
@return Кортеж «тип строки, список полей».
|
||||
@throws SpectrumStreamError При нарушении формата или суммы.
|
||||
"""
|
||||
if not line.startswith(LINE_START):
|
||||
raise SpectrumStreamError("строка не начинается с '$'")
|
||||
|
||||
star = line.rfind(CHECKSUM_MARK)
|
||||
if star < 0:
|
||||
raise SpectrumStreamError("нет контрольной суммы")
|
||||
|
||||
payload = line[1:star]
|
||||
tail = line[star + 1 :].strip()
|
||||
|
||||
if len(tail) != 2:
|
||||
raise SpectrumStreamError("контрольная сумма не из двух знаков")
|
||||
|
||||
try:
|
||||
expected = int(tail, 16)
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError("контрольная сумма не шестнадцатеричная") from exc
|
||||
|
||||
actual = checksum(payload)
|
||||
if actual != expected:
|
||||
raise SpectrumStreamError(
|
||||
f"контрольная сумма не сошлась: получено {expected:02X}, "
|
||||
f"посчитано {actual:02X}"
|
||||
)
|
||||
|
||||
fields = payload.split(",")
|
||||
if not fields:
|
||||
raise SpectrumStreamError("пустая строка")
|
||||
|
||||
return fields[0], fields[1:]
|
||||
|
||||
|
||||
def _decode_levels(hex_data: str, top_dbmv: float, range_db: float) -> tuple[float, ...]:
|
||||
"""@brief Разворачивает упакованный спектр в уровни.
|
||||
|
||||
@param hex_data Элементы спектра, по два знака на элемент.
|
||||
@param top_dbmv Верхняя граница шкалы.
|
||||
@param range_db Размах шкалы.
|
||||
@return Уровни в децибелах.
|
||||
|
||||
Прибор передаёт каждый элемент одним байтом: ноль означает нижнюю
|
||||
границу шкалы, 255 — верхнюю. Такое сжатие теряет доли децибела,
|
||||
что для картины спектра несущественно, зато уменьшает кадр вчетверо
|
||||
относительно передачи чисел текстом.
|
||||
"""
|
||||
if len(hex_data) % 2 != 0:
|
||||
raise SpectrumStreamError("нечётное число знаков в данных спектра")
|
||||
|
||||
bottom = top_dbmv - range_db
|
||||
levels: list[float] = []
|
||||
|
||||
for pos in range(0, len(hex_data), 2):
|
||||
chunk = hex_data[pos : pos + 2]
|
||||
try:
|
||||
raw = int(chunk, 16)
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError(f"неверный знак в данных: '{chunk}'") from exc
|
||||
levels.append(bottom + (raw / 255.0) * range_db)
|
||||
|
||||
return tuple(levels)
|
||||
|
||||
|
||||
def parse_line(line: str) -> SpectrumFrame | HarmonicsFrame:
|
||||
"""@brief Разбирает одну строку.
|
||||
|
||||
@param line Строка без перевода строки.
|
||||
@return Кадр спектра или кадр гармоник.
|
||||
@throws SpectrumStreamError При любой ошибке разбора.
|
||||
"""
|
||||
tag, fields = _split_line(line)
|
||||
|
||||
if tag == TAG_SPECTRUM:
|
||||
if len(fields) != 7:
|
||||
raise SpectrumStreamError(
|
||||
f"в строке спектра {len(fields)} полей вместо 7"
|
||||
)
|
||||
try:
|
||||
seq = int(fields[0])
|
||||
sample_rate = int(fields[1])
|
||||
fft_size = int(fields[2])
|
||||
bins = int(fields[3])
|
||||
top = float(fields[4])
|
||||
span = float(fields[5])
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError("нечисловое поле в строке спектра") from exc
|
||||
|
||||
if span <= 0.0:
|
||||
raise SpectrumStreamError("размах шкалы должен быть положительным")
|
||||
|
||||
levels = _decode_levels(fields[6], top, span)
|
||||
|
||||
# Заявленное число элементов обязано совпасть с переданным: иначе
|
||||
# шкала частот сдвинется, и оператор увидит тон не на своей частоте.
|
||||
if len(levels) != bins:
|
||||
raise SpectrumStreamError(
|
||||
f"заявлено {bins} элементов, передано {len(levels)}"
|
||||
)
|
||||
|
||||
return SpectrumFrame(
|
||||
seq=seq,
|
||||
sample_rate_hz=sample_rate,
|
||||
fft_size=fft_size,
|
||||
top_dbmv=top,
|
||||
range_db=span,
|
||||
levels_db=levels,
|
||||
)
|
||||
|
||||
if tag == TAG_HARMONICS:
|
||||
if len(fields) < 6:
|
||||
raise SpectrumStreamError("слишком короткая строка гармоник")
|
||||
try:
|
||||
seq = int(fields[0])
|
||||
fundamental_hz = float(fields[1])
|
||||
fundamental_mv = float(fields[2])
|
||||
thd = float(fields[3])
|
||||
snr = float(fields[4])
|
||||
rms = float(fields[5])
|
||||
count = int(fields[6]) if len(fields) > 6 else 0
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError("нечисловое поле в строке гармоник") from exc
|
||||
|
||||
if count < 0 or count > MAX_HARMONICS:
|
||||
raise SpectrumStreamError(f"недопустимое число гармоник: {count}")
|
||||
|
||||
expected_fields = 7 + count * 3
|
||||
if len(fields) != expected_fields:
|
||||
raise SpectrumStreamError(
|
||||
f"для {count} гармоник нужно {expected_fields} полей, "
|
||||
f"получено {len(fields)}"
|
||||
)
|
||||
|
||||
harmonics: list[Harmonic] = []
|
||||
for item in range(count):
|
||||
base = 7 + item * 3
|
||||
try:
|
||||
harmonics.append(
|
||||
Harmonic(
|
||||
index=item + 2,
|
||||
frequency_hz=float(fields[base]),
|
||||
amplitude_mv=float(fields[base + 1]),
|
||||
relative_db=float(fields[base + 2]),
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError(
|
||||
f"нечисловое поле у гармоники {item + 2}"
|
||||
) from exc
|
||||
|
||||
return HarmonicsFrame(
|
||||
seq=seq,
|
||||
fundamental_hz=fundamental_hz,
|
||||
fundamental_mv=fundamental_mv,
|
||||
thd_percent=thd,
|
||||
snr_db=snr,
|
||||
rms_mv=rms,
|
||||
harmonics=tuple(harmonics),
|
||||
)
|
||||
|
||||
raise SpectrumStreamError(f"неизвестный тип строки: '{tag}'")
|
||||
|
||||
|
||||
class SpectrumStream:
|
||||
"""@brief Накопитель байтов, отдающий разобранные кадры.
|
||||
|
||||
Данные из порта приходят кусками произвольного размера: строка может
|
||||
прийти по частям, а в одном куске может оказаться несколько строк.
|
||||
Накопитель собирает их обратно.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = ""
|
||||
self.parsed = 0
|
||||
"""Число успешно разобранных строк."""
|
||||
self.errors = 0
|
||||
"""Число отброшенных строк."""
|
||||
self.last_error: str = ""
|
||||
"""Причина последней ошибки — для журнала вкладки."""
|
||||
|
||||
def reset(self) -> None:
|
||||
"""@brief Сбрасывает накопитель и счётчики."""
|
||||
self._buffer = ""
|
||||
self.parsed = 0
|
||||
self.errors = 0
|
||||
self.last_error = ""
|
||||
|
||||
def feed(self, chunk: str | bytes) -> list[SpectrumFrame | HarmonicsFrame]:
|
||||
"""@brief Добавляет очередной кусок потока.
|
||||
|
||||
@param chunk Данные из порта.
|
||||
@return Список кадров, разобранных из накопленного.
|
||||
|
||||
Ошибочные строки отбрасываются, но не прерывают разбор: одиночная
|
||||
помеха в канале не должна ронять поток целиком.
|
||||
"""
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("ascii", errors="replace")
|
||||
|
||||
self._buffer += chunk
|
||||
|
||||
# Защита от мусора без переводов строки: иначе буфер рос бы
|
||||
# неограниченно и приложение съело бы всю память.
|
||||
if len(self._buffer) > MAX_LINE_LENGTH:
|
||||
cut = self._buffer.rfind("\n")
|
||||
self._buffer = self._buffer[cut + 1 :] if cut >= 0 else ""
|
||||
self.errors += 1
|
||||
self.last_error = "строка длиннее допустимой, буфер очищен"
|
||||
|
||||
frames: list[SpectrumFrame | HarmonicsFrame] = []
|
||||
|
||||
while "\n" in self._buffer:
|
||||
line, _, self._buffer = self._buffer.partition("\n")
|
||||
line = line.strip("\r\n \t")
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
frames.append(parse_line(line))
|
||||
self.parsed += 1
|
||||
except SpectrumStreamError as exc:
|
||||
self.errors += 1
|
||||
self.last_error = str(exc)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def build_spectrum_line(
|
||||
seq: int,
|
||||
sample_rate_hz: int,
|
||||
fft_size: int,
|
||||
levels_db: list[float] | tuple[float, ...],
|
||||
top_dbmv: float,
|
||||
range_db: float,
|
||||
) -> str:
|
||||
"""@brief Собирает строку спектра.
|
||||
|
||||
@return Строка с переводом строки в конце.
|
||||
|
||||
Нужна для тестов и для демонстрационного источника, но главное — она
|
||||
задаёт формат в одном месте с разбором. Если формат изменят только
|
||||
в прошивке, круговой тест это заметит.
|
||||
"""
|
||||
bottom = top_dbmv - range_db
|
||||
parts: list[str] = []
|
||||
|
||||
for level in levels_db:
|
||||
ratio = (level - bottom) / range_db if range_db > 0.0 else 0.0
|
||||
raw = int(round(max(0.0, min(1.0, ratio)) * 255.0))
|
||||
parts.append(f"{raw:02X}")
|
||||
|
||||
payload = (
|
||||
f"{TAG_SPECTRUM},{seq},{sample_rate_hz},{fft_size},"
|
||||
f"{len(parts)},{top_dbmv:.1f},{range_db:.1f},{''.join(parts)}"
|
||||
)
|
||||
return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
|
||||
|
||||
|
||||
def build_harmonics_line(frame: HarmonicsFrame) -> str:
|
||||
"""@brief Собирает строку гармоник.
|
||||
|
||||
@param frame Данные для передачи.
|
||||
@return Строка с переводом строки в конце.
|
||||
"""
|
||||
parts = [
|
||||
TAG_HARMONICS,
|
||||
str(frame.seq),
|
||||
f"{frame.fundamental_hz:.1f}",
|
||||
f"{frame.fundamental_mv:.1f}",
|
||||
f"{frame.thd_percent:.2f}",
|
||||
f"{frame.snr_db:.1f}",
|
||||
f"{frame.rms_mv:.1f}",
|
||||
str(len(frame.harmonics)),
|
||||
]
|
||||
for harmonic in frame.harmonics:
|
||||
parts.append(f"{harmonic.frequency_hz:.1f}")
|
||||
parts.append(f"{harmonic.amplitude_mv:.1f}")
|
||||
parts.append(f"{harmonic.relative_db:.1f}")
|
||||
|
||||
payload = ",".join(parts)
|
||||
return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
|
||||
Reference in New Issue
Block a user