"""@file spectrum_stream.py @brief Разбор потока спектра и гармоник от прибора на STM32. Прибор снимает сигнал вибродатчика или микрофона, считает быстрое преобразование Фурье прямо на месте и передаёт готовый результат: сам спектр и параметры найденных гармоник. Задача этого модуля — превратить поток байтов в структуры, с которыми работает вкладка. Модуль не зависит от Qt и от последовательного порта: на вход подаются байты, на выход идут кадры. Так его можно проверить тестами без прибора и без окна. Формат строки повторяет привычный по навигационным приборам вид: доллар, поля через запятую, звёздочка и контрольная сумма:: $SPEC,,,,,,,* $HARM,,,,,,,[,,,]...* Текстовый формат выбран намеренно, хотя двоичный вышел бы компактнее. Прибор отлаживается через обычный терминал, и возможность увидеть поток глазами дороже экономии канала. Спектр при этом ужат до одного байта на элемент, поэтому кадр укладывается примерно в 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"