Files
templates/python/set_devices/spectrum_stream.py

421 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""@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"