From 26c3dd7ad56a1d034e8d04c543f16556df308ed1 Mon Sep 17 00:00:00 2001 From: awe Date: Thu, 5 Mar 2026 17:56:27 +0300 Subject: [PATCH] something working new format --- calib_envelope.npy | Bin 3164 -> 5852 bytes rfg_adc_plotter/gui/matplotlib_backend.py | 51 ++---- rfg_adc_plotter/gui/pyqtgraph_backend.py | 45 ++---- .../io/capture_reference_loader.py | 22 +-- rfg_adc_plotter/io/sweep_parser_core.py | 145 ++++++++++++++++-- rfg_adc_plotter/io/sweep_reader.py | 23 ++- rfg_adc_plotter/state/ring_buffer.py | 91 +---------- tests/test_capture_reference_loader.py | 18 +++ tests/test_ring_buffer_fft_axis.py | 38 ++--- ...test_sweep_parser_core_binary_protocols.py | 110 +++++++++++++ 10 files changed, 320 insertions(+), 223 deletions(-) create mode 100644 tests/test_sweep_parser_core_binary_protocols.py diff --git a/calib_envelope.npy b/calib_envelope.npy index fc87840309c2c89886cdf68e634b66a2b54ba7bd..0d28901039ecfcfda9e36815fb563d7481fe9226 100644 GIT binary patch literal 5852 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 C@NQ55 diff --git a/rfg_adc_plotter/gui/matplotlib_backend.py b/rfg_adc_plotter/gui/matplotlib_backend.py index 774b6b3..60bb02a 100644 --- a/rfg_adc_plotter/gui/matplotlib_backend.py +++ b/rfg_adc_plotter/gui/matplotlib_backend.py @@ -146,9 +146,7 @@ def run_matplotlib(args): ax_line.set_ylim(fixed_ylim) # График спектра - fft_line_t1, = ax_fft.plot([], [], lw=1, color="tab:blue", label="1/3 (low f)") - fft_line_t2, = ax_fft.plot([], [], lw=1, color="tab:orange", label="2/3 (mid f)") - fft_line_t3, = ax_fft.plot([], [], lw=1, color="tab:green", label="3/3 (high f)") + fft_line_obj, = ax_fft.plot([], [], lw=1, color="tab:blue", label="full band") ax_fft.set_title("FFT", pad=1) ax_fft.set_xlabel("Глубина, м") ax_fft.set_ylabel("Амплитуда") @@ -438,9 +436,7 @@ def run_matplotlib(args): ring.set_fft_complex_mode(str(label)) except Exception: pass - fft_line_t1.set_data([], []) - fft_line_t2.set_data([], []) - fft_line_t3.set_data([], []) + fft_line_obj.set_data([], []) _refresh_status_texts() try: fig.canvas.draw_idle() @@ -590,31 +586,20 @@ def run_matplotlib(args): ax_line.autoscale_view(scalex=False, scaley=True) ax_line.set_ylabel("Y") - # Профиль по глубине: три линии для 1/3, 2/3, 3/3 частотного диапазона. - third_axes = ring.last_fft_third_axes_m - third_vals = ring.last_fft_third_vals - lines = (fft_line_t1, fft_line_t2, fft_line_t3) - xs_max = [] - ys_min = [] - ys_max = [] - for line_fft, xs_fft, fft_vals in zip(lines, third_axes, third_vals): - if xs_fft is None or fft_vals is None: - line_fft.set_data([], []) - continue - n = min(int(xs_fft.size), int(fft_vals.size)) - if n <= 0: - line_fft.set_data([], []) - continue - x_seg = xs_fft[:n] - y_seg = fft_vals[:n] - line_fft.set_data(x_seg, y_seg) - xs_max.append(float(x_seg[n - 1])) - ys_min.append(float(np.nanmin(y_seg))) - ys_max.append(float(np.nanmax(y_seg))) - - if xs_max and ys_min and ys_max: - ax_fft.set_xlim(0, float(max(xs_max))) - ax_fft.set_ylim(float(min(ys_min)), float(max(ys_max))) + axis_fft = ring.fft_depth_axis_m + vals_fft = ring.last_fft_vals + if axis_fft is None or vals_fft is None: + fft_line_obj.set_data([], []) + else: + n_fft = min(int(axis_fft.size), int(vals_fft.size)) + if n_fft <= 0: + fft_line_obj.set_data([], []) + else: + x_fft = axis_fft[:n_fft] + y_fft = vals_fft[:n_fft] + fft_line_obj.set_data(x_fft, y_fft) + ax_fft.set_xlim(0, float(x_fft[n_fft - 1])) + ax_fft.set_ylim(float(np.nanmin(y_fft)), float(np.nanmax(y_fft))) # Водопад сырых данных if changed and ring.is_ready: @@ -664,9 +649,7 @@ def run_matplotlib(args): line_env_lo, line_env_hi, img_obj, - fft_line_t1, - fft_line_t2, - fft_line_t3, + fft_line_obj, img_fft_obj, status_text, pipeline_text, diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 58d4c12..078e603 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -202,9 +202,7 @@ def run_pyqtgraph(args): # FFT (слева-снизу) p_fft = win.addPlot(row=1, col=0, title="FFT") p_fft.showGrid(x=True, y=True, alpha=0.3) - curve_fft_t1 = p_fft.plot(pen=pg.mkPen((80, 120, 255), width=1)) - curve_fft_t2 = p_fft.plot(pen=pg.mkPen((255, 140, 70), width=1)) - curve_fft_t3 = p_fft.plot(pen=pg.mkPen((60, 180, 90), width=1)) + curve_fft = p_fft.plot(pen=pg.mkPen((80, 120, 255), width=1)) p_fft.setLabel("bottom", "Глубина, м") p_fft.setLabel("left", "Амплитуда") @@ -494,9 +492,7 @@ def run_pyqtgraph(args): changed = False if changed: try: - curve_fft_t1.setData([], []) - curve_fft_t2.setData([], []) - curve_fft_t3.setData([], []) + curve_fft.setData([], []) except Exception: pass _refresh_pipeline_label() @@ -630,31 +626,20 @@ def run_pyqtgraph(args): p_line.enableAutoRange(axis="y", enable=True) p_line.setLabel("left", "Y") - # Профиль по глубине: три линии для 1/3, 2/3, 3/3 частотного диапазона. - third_axes = ring.last_fft_third_axes_m - third_vals = ring.last_fft_third_vals - curves = (curve_fft_t1, curve_fft_t2, curve_fft_t3) - xs_max = [] - ys_min = [] - ys_max = [] - for curve_fft, xs_fft, fft_vals in zip(curves, third_axes, third_vals): - if xs_fft is None or fft_vals is None: + axis_fft = ring.fft_depth_axis_m + vals_fft = ring.last_fft_vals + if axis_fft is None or vals_fft is None: + curve_fft.setData([], []) + else: + n_fft = min(int(axis_fft.size), int(vals_fft.size)) + if n_fft <= 0: curve_fft.setData([], []) - continue - n = min(int(xs_fft.size), int(fft_vals.size)) - if n <= 0: - curve_fft.setData([], []) - continue - x_seg = xs_fft[:n] - y_seg = fft_vals[:n] - curve_fft.setData(x_seg, y_seg) - xs_max.append(float(x_seg[n - 1])) - ys_min.append(float(np.nanmin(y_seg))) - ys_max.append(float(np.nanmax(y_seg))) - - if xs_max and ys_min and ys_max: - p_fft.setXRange(0.0, float(max(xs_max)), padding=0) - p_fft.setYRange(float(min(ys_min)), float(max(ys_max)), padding=0) + else: + x_fft = axis_fft[:n_fft] + y_fft = vals_fft[:n_fft] + curve_fft.setData(x_fft, y_fft) + p_fft.setXRange(0.0, float(x_fft[n_fft - 1]), padding=0) + p_fft.setYRange(float(np.nanmin(y_fft)), float(np.nanmax(y_fft)), padding=0) # Позиция подписи канала try: diff --git a/rfg_adc_plotter/io/capture_reference_loader.py b/rfg_adc_plotter/io/capture_reference_loader.py index 5fc519b..e8089b8 100644 --- a/rfg_adc_plotter/io/capture_reference_loader.py +++ b/rfg_adc_plotter/io/capture_reference_loader.py @@ -46,31 +46,23 @@ def detect_reference_file_format(path: str) -> Optional[str]: size = os.path.getsize(p) except Exception: return None - if size <= 0 or (size % 8) != 0: + if size <= 0: return None try: with open(p, "rb") as f: - sample = f.read(min(size, 8 * 2048)) + sample = f.read(min(size, 256 * 1024)) except Exception: return None if len(sample) < 8: return None - # Быстрый sniff aligned-записей: в валидных записях байт 6 == 0x0A. - recs = len(sample) // 8 - if recs <= 0: - return None - marker_hits = 0 - start_hits = 0 - for i in range(0, recs * 8, 8): - b = sample[i : i + 8] - if b[6] == 0x0A: - marker_hits += 1 - if b[:6] == b"\xff\xff\xff\xff\xff\xff": - start_hits += 1 - if marker_hits >= max(4, int(recs * 0.8)) and start_hits >= 1: + # Универсальный sniff: прогоняем тем же потоковым парсером, + # который используется в realtime/capture-import. + parser = BinaryRecordStreamParser() + _ = parser.feed(sample) + if parser.start_count >= 1 and parser.point_count >= 16: return "bin_capture" return None diff --git a/rfg_adc_plotter/io/sweep_parser_core.py b/rfg_adc_plotter/io/sweep_parser_core.py index 1ae8dc0..c0c1d1d 100644 --- a/rfg_adc_plotter/io/sweep_parser_core.py +++ b/rfg_adc_plotter/io/sweep_parser_core.py @@ -2,9 +2,10 @@ from __future__ import annotations +import math from collections import deque import time -from typing import Iterable, List, Optional, Sequence, Set, Tuple +from typing import List, Optional, Sequence, Set, Tuple import numpy as np @@ -14,7 +15,13 @@ from rfg_adc_plotter.types import SweepInfo, SweepPacket # Binary parser events: # ("start", ch) # ("point", ch, x, y) -BinaryEvent = Tuple[str, int] | Tuple[str, int, int, int] +BinaryEvent = Tuple[str, int] | Tuple[str, int, int, float] + +# Параметры преобразования пары log-detector значений в линейную амплитуду. +_LOG_DETECTOR_BASE = 10.0 +_LOG_DETECTOR_SCALER = 0.001 +_LOG_DETECTOR_POSTSCALE = 1000.0 +_LOG_DETECTOR_EXP_LIMIT = 300.0 def u32_to_i32(v: int) -> int: @@ -22,8 +29,44 @@ def u32_to_i32(v: int) -> int: return v - 0x1_0000_0000 if (v & 0x8000_0000) else v +def u_bits_to_i(v: int, bits: int) -> int: + """Преобразование беззнакового целого fixed-width в знаковое (two's complement).""" + if bits <= 0: + return 0 + sign = 1 << (bits - 1) + full = 1 << bits + return v - full if (v & sign) else v + + +def words_be_to_i(words: Sequence[int]) -> int: + """Собрать big-endian набор 16-bit слов в знаковое число.""" + acc = 0 + for w in words: + acc = (acc << 16) | (int(w) & 0xFFFF) + return u_bits_to_i(acc, 16 * int(len(words))) + + +def _log_pair_to_linear(avg_1: int, avg_2: int) -> float: + """Разность двух логарифмических усреднений в линейной шкале.""" + exp1 = max(-_LOG_DETECTOR_EXP_LIMIT, min(_LOG_DETECTOR_EXP_LIMIT, float(avg_1) * _LOG_DETECTOR_SCALER)) + exp2 = max(-_LOG_DETECTOR_EXP_LIMIT, min(_LOG_DETECTOR_EXP_LIMIT, float(avg_2) * _LOG_DETECTOR_SCALER)) + return (math.pow(_LOG_DETECTOR_BASE, exp1) - math.pow(_LOG_DETECTOR_BASE, exp2)) * _LOG_DETECTOR_POSTSCALE + + class BinaryRecordStreamParser: - """Инкрементальный парсер бинарных записей протокола (по 8 байт).""" + """Инкрементальный парсер бинарных записей нескольких wire-форматов. + + Поддерживаемые форматы: + 1) legacy 8-byte: + старт: 0xFFFF,0xFFFF,0xFFFF,(ch<<8)|0x0A + точка: step,value_hi16,value_lo16,(ch<<8)|0x0A + 2) log-detector: + старт: 0xFFFF x5, (ch<<8)|0x0A + точка: step, avg1, avg2, (ch<<8)|0x0A, + где avg1/avg2 кодируются фиксированной шириной в 16-bit словах: + - 2 слова (int32) или + - 8 слов (int128). + """ def __init__(self): self._buf = bytearray() @@ -31,6 +74,49 @@ class BinaryRecordStreamParser: self.start_count: int = 0 self.point_count: int = 0 self.desync_count: int = 0 + self._log_pair_words: Optional[int] = None + + @staticmethod + def _u16_at(buf: bytearray, offset: int) -> int: + return int(buf[offset]) | (int(buf[offset + 1]) << 8) + + def _try_parse_log_start(self, buf: bytearray) -> Optional[Tuple[int, int]]: + rec_bytes = 12 # 6 слов: FFFF x5 + terminator + if len(buf) < rec_bytes: + return None + for wi in range(5): + if self._u16_at(buf, wi * 2) != 0xFFFF: + return None + term = self._u16_at(buf, 10) + if (term & 0x00FF) != 0x000A: + return None + ch = int((term >> 8) & 0x00FF) + return ch, rec_bytes + + def _try_parse_log_point(self, buf: bytearray, pair_words: int) -> Optional[Tuple[int, int, float, int]]: + if pair_words <= 0: + return None + rec_words = 2 + 2 * int(pair_words) + rec_bytes = 2 * rec_words + if len(buf) < rec_bytes: + return None + + step = self._u16_at(buf, 0) + if step == 0xFFFF: + return None + + term_off = rec_bytes - 2 + term = self._u16_at(buf, term_off) + if (term & 0x00FF) != 0x000A: + return None + + a1_words = [self._u16_at(buf, 2 + 2 * i) for i in range(pair_words)] + a2_words = [self._u16_at(buf, 2 + 2 * (pair_words + i)) for i in range(pair_words)] + avg_1 = words_be_to_i(a1_words) + avg_2 = words_be_to_i(a2_words) + y_val = _log_pair_to_linear(avg_1, avg_2) + ch = int((term >> 8) & 0x00FF) + return ch, int(step), float(y_val), rec_bytes def feed(self, data: bytes) -> List[BinaryEvent]: if data: @@ -39,22 +125,57 @@ class BinaryRecordStreamParser: buf = self._buf while len(buf) >= 8: - w0 = int(buf[0]) | (int(buf[1]) << 8) - w1 = int(buf[2]) | (int(buf[3]) << 8) - w2 = int(buf[4]) | (int(buf[5]) << 8) + # 1) log-detector start (12-byte): FFFF x5 + (ch<<8)|0x0A + parsed_log_start = self._try_parse_log_start(buf) + if parsed_log_start is not None: + ch, consumed = parsed_log_start + events.append(("start", ch)) + del buf[:consumed] + self.bytes_consumed += consumed + self.start_count += 1 + # Ширину пары (32/128) определим на ближайшей точке. + self._log_pair_words = None + continue + # 2) log-detector point: + # сперва в уже известной ширине пары, иначе авто-детект 128/32. + # В авто-режиме сначала пробуем 32-bit пару (наиболее частый формат), + # затем 128-bit. Это снижает риск ложного совпадения 128-bit длины на 32-bit потоке. + pair_candidates = [self._log_pair_words] if self._log_pair_words in (2, 8) else [2, 8] + parsed_log_point: Optional[Tuple[int, int, float, int]] = None + for pair_words in pair_candidates: + if pair_words is None: + continue + parsed_log_point = self._try_parse_log_point(buf, int(pair_words)) + if parsed_log_point is not None: + self._log_pair_words = int(pair_words) + break + if parsed_log_point is not None: + ch, step, y_val, consumed = parsed_log_point + events.append(("point", ch, step, y_val)) + del buf[:consumed] + self.bytes_consumed += consumed + self.point_count += 1 + continue + + # 3) legacy 8-byte start / point. + w0 = self._u16_at(buf, 0) + w1 = self._u16_at(buf, 2) + w2 = self._u16_at(buf, 4) if w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and buf[6] == 0x0A: ch = int(buf[7]) events.append(("start", ch)) del buf[:8] self.bytes_consumed += 8 self.start_count += 1 + # legacy не использует пару avg1/avg2. + self._log_pair_words = None continue if buf[6] == 0x0A: ch = int(buf[7]) value_u32 = (w1 << 16) | w2 - events.append(("point", ch, int(w0), u32_to_i32(value_u32))) + events.append(("point", ch, int(w0), float(u32_to_i32(value_u32)))) del buf[:8] self.bytes_consumed += 8 self.point_count += 1 @@ -88,7 +209,7 @@ class SweepAssembler: self._n_valid_hist = deque() self._xs: list[int] = [] - self._ys: list[int] = [] + self._ys: list[float] = [] self._cur_channel: Optional[int] = None self._cur_channels: set[int] = set() @@ -98,12 +219,12 @@ class SweepAssembler: self._cur_channel = None self._cur_channels.clear() - def add_point(self, ch: int, x: int, y: int): + def add_point(self, ch: int, x: int, y: float): if self._cur_channel is None: self._cur_channel = int(ch) self._cur_channels.add(int(ch)) self._xs.append(int(x)) - self._ys.append(int(y)) + self._ys.append(float(y)) def start_new_sweep(self, ch: int, now_ts: Optional[float] = None) -> Optional[SweepPacket]: packet = self.finalize_current(now_ts=now_ts) @@ -122,13 +243,13 @@ class SweepAssembler: return out # point _tag, ch, x, y = event # type: ignore[misc] - self.add_point(int(ch), int(x), int(y)) + self.add_point(int(ch), int(x), float(y)) return out def finalize_arrays( self, xs: Sequence[int], - ys: Sequence[int], + ys: Sequence[float], channels: Optional[Set[int]], now_ts: Optional[float] = None, ) -> Optional[SweepPacket]: diff --git a/rfg_adc_plotter/io/sweep_reader.py b/rfg_adc_plotter/io/sweep_reader.py index bdc8c0f..509beb0 100644 --- a/rfg_adc_plotter/io/sweep_reader.py +++ b/rfg_adc_plotter/io/sweep_reader.py @@ -151,20 +151,17 @@ class SweepReader(threading.Thread): def _run_binary_stream(self, chunk_reader: SerialChunkReader): xs: list[int] = [] - ys: list[int] = [] + ys: list[float] = [] cur_channel: Optional[int] = None cur_channels: set[int] = set() parser = BinaryRecordStreamParser() - # Бинарный протокол (4 слова LE u16 = 8 байт на запись): - # старт свипа: 0xFFFF, 0xFFFF, 0xFFFF, (ch<<8)|0x0A - # Байты на проводе: ff ff ff ff ff ff 0a [ch] - # ch=0 → последнее слово=0x000A; ch=1 → 0x010A; и т.д. - # точка данных: step_u16, value_hi_u16, value_lo_u16, (ch<<8)|0x0A - # Байты на проводе: [step_lo step_hi] [hi_lo hi_hi] [lo_lo lo_hi] 0a [ch] - # value_i32 = sign_extend((value_hi<<16)|value_lo) - # Признак записи: байт 6 == 0x0A, байт 7 — номер канала. - # При десинхронизации сдвигаемся на 1 БАЙТ (не слово) для самосинхронизации. + # Поддерживаются оба wire-формата: + # 1) legacy: 8-byte записи (start/point с одним int32 значением). + # 2) log-detector: start = FFFF x5 + (ch<<8)|0x0A, + # point = step + (avg1, avg2), где avg1/avg2 имеют ширину 32-bit или 128-bit. + # Для point парсер сразу преобразует (avg1, avg2) в линейную амплитуду y. + # В обоих режимах при десинхронизации parser.feed() сдвигается на 1 байт. _dbg_byte_count = 0 _dbg_desync_count = 0 @@ -196,13 +193,13 @@ class SweepReader(threading.Thread): _tag, ch_from_term, step, value_i32 = ev # type: ignore[misc] if cur_channel is None: cur_channel = int(ch_from_term) - cur_channels.add(int(cur_channel)) + cur_channels.add(int(ch_from_term)) xs.append(int(step)) - ys.append(int(value_i32)) + ys.append(float(value_i32)) _dbg_point_count += 1 if self._debug and _dbg_point_count <= 3: sys.stderr.write( - f"[debug] BIN точка: step={int(step)} ch={int(ch_from_term)} → value={int(value_i32)}\n" + f"[debug] BIN точка: step={int(step)} ch={int(ch_from_term)} → value={float(value_i32):.3f}\n" ) _dbg_byte_count = parser.bytes_consumed diff --git a/rfg_adc_plotter/state/ring_buffer.py b/rfg_adc_plotter/state/ring_buffer.py index fc22f08..2f6a307 100644 --- a/rfg_adc_plotter/state/ring_buffer.py +++ b/rfg_adc_plotter/state/ring_buffer.py @@ -11,10 +11,7 @@ from rfg_adc_plotter.constants import ( WF_WIDTH, ) from rfg_adc_plotter.processing.fourier import ( - build_frequency_axis_hz, compute_ifft_profile_from_sweep, - perform_ifft_depth_response, - reconstruct_complex_spectrum_from_real_trace, ) @@ -43,17 +40,6 @@ class RingBuffer: self.y_max_fft: Optional[float] = None # FFT последнего свипа (для отображения без повторного вычисления) self.last_fft_vals: Optional[np.ndarray] = None - # FFT-профили по третям входного частотного диапазона (для line-plot). - self.last_fft_third_axes_m: tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]] = ( - None, - None, - None, - ) - self.last_fft_third_vals: tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]] = ( - None, - None, - None, - ) @property def is_ready(self) -> bool: @@ -80,8 +66,6 @@ class RingBuffer: self.fft_depth_axis_m = None self.fft_bins = 0 self.last_fft_vals = None - self.last_fft_third_axes_m = (None, None, None) - self.last_fft_third_vals = (None, None, None) self.y_min_fft = None self.y_max_fft = None return True @@ -112,11 +96,6 @@ class RingBuffer: self._push_fft(s) def _push_fft(self, s: np.ndarray): - empty_thirds = ( - np.zeros((0,), dtype=np.float32), - np.zeros((0,), dtype=np.float32), - np.zeros((0,), dtype=np.float32), - ) depth_axis_m, fft_row = compute_ifft_profile_from_sweep( s, complex_mode=self.fft_complex_mode, @@ -126,21 +105,13 @@ class RingBuffer: n = min(int(fft_row.size), int(depth_axis_m.size)) if n <= 0: - self.last_fft_third_axes_m = empty_thirds - self.last_fft_third_vals = empty_thirds + self.last_fft_vals = None return if n != fft_row.size: fft_row = fft_row[:n] if n != depth_axis_m.size: depth_axis_m = depth_axis_m[:n] - # Для отображения храним только первую половину IFFT-профиля: - # вторая половина для текущей схемы симметрична и визуально избыточна. - n_keep = max(1, (n + 1) // 2) - fft_row = fft_row[:n_keep] - depth_axis_m = depth_axis_m[:n_keep] - n = n_keep - needs_reset = ( self.ring_fft is None or self.fft_depth_axis_m is None @@ -169,7 +140,6 @@ class RingBuffer: prev_head = (self.head - 1) % self.ring_fft.shape[0] self.ring_fft[prev_head, :] = fft_row self.last_fft_vals = fft_row - self.last_fft_third_axes_m, self.last_fft_third_vals = self._compute_fft_thirds(s) fr_min = np.nanmin(fft_row) fr_max = float(np.nanpercentile(fft_row, 90)) @@ -178,65 +148,6 @@ class RingBuffer: if self.y_max_fft is None or (not np.isnan(fr_max) and fr_max > self.y_max_fft): self.y_max_fft = float(fr_max) - def _compute_fft_thirds( - self, s: np.ndarray - ) -> tuple[tuple[np.ndarray, np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray, np.ndarray]]: - sweep = np.asarray(s, dtype=np.float64).ravel() - total = int(sweep.size) - - def _empty() -> np.ndarray: - return np.zeros((0,), dtype=np.float32) - - if total <= 0: - return (_empty(), _empty(), _empty()), (_empty(), _empty(), _empty()) - - freq_hz = build_frequency_axis_hz(total) - edges = np.linspace(0, total, 4, dtype=np.int64) - - axes: list[np.ndarray] = [] - vals: list[np.ndarray] = [] - - for idx in range(3): - i0 = int(edges[idx]) - i1 = int(edges[idx + 1]) - if i1 - i0 < 2: - axes.append(_empty()) - vals.append(_empty()) - continue - - seg = sweep[i0:i1] - seg_freq = freq_hz[i0:i1] - seg_complex = reconstruct_complex_spectrum_from_real_trace( - seg, - complex_mode=self.fft_complex_mode, - ) - depth_m, seg_fft = perform_ifft_depth_response(seg_complex, seg_freq, axis="abs") - - depth_m = np.asarray(depth_m, dtype=np.float32).ravel() - seg_fft = np.asarray(seg_fft, dtype=np.float32).ravel() - n = min(int(depth_m.size), int(seg_fft.size)) - if n <= 0: - axes.append(_empty()) - vals.append(_empty()) - continue - - depth_m = depth_m[:n] - seg_fft = seg_fft[:n] - - n_keep = max(1, (n + 1) // 2) - axes.append(depth_m[:n_keep]) - vals.append(seg_fft[:n_keep]) - - return ( - axes[0], - axes[1], - axes[2], - ), ( - vals[0], - vals[1], - vals[2], - ) - def get_display_ring(self) -> np.ndarray: """Кольцо в порядке от старого к новому, ось времени по X. Форма: (width, time).""" if self.ring is None: diff --git a/tests/test_capture_reference_loader.py b/tests/test_capture_reference_loader.py index ff510b4..7aba5cc 100644 --- a/tests/test_capture_reference_loader.py +++ b/tests/test_capture_reference_loader.py @@ -13,11 +13,13 @@ from rfg_adc_plotter.processing.pipeline import SweepPreprocessor ROOT = Path(__file__).resolve().parents[1] SAMPLE_BG = ROOT / "sample_data" / "empty" SAMPLE_CALIB = ROOT / "sample_data" / "no_antennas_35dB_attenuators" +SAMPLE_NEW_FMT = ROOT / "sample_data" / "new_format" / "attenuators_50dB" def test_detect_reference_file_format_for_sample_capture(): assert detect_reference_file_format(str(SAMPLE_BG)) == "bin_capture" assert detect_reference_file_format(str(SAMPLE_CALIB)) == "bin_capture" + assert detect_reference_file_format(str(SAMPLE_NEW_FMT)) == "bin_capture" def test_load_capture_sweeps_parses_binary_capture(): @@ -33,6 +35,22 @@ def test_load_capture_sweeps_parses_binary_capture(): assert channels == {0} +def test_load_capture_sweeps_parses_new_format_logdetector_capture(): + sweeps = load_capture_sweeps(str(SAMPLE_NEW_FMT), fancy=False, logscale=False) + assert len(sweeps) > 900 + + widths = [int(s.size) for s, _info in sweeps] + dominant_width = max(set(widths), key=widths.count) + # Должно совпадать с ожидаемой шириной свипа из штатных capture. + assert dominant_width in (758, 759) + + channels = set() + for _s, info in sweeps: + chs = info.get("chs", [info.get("ch", 0)]) + channels.update(int(v) for v in chs) + assert channels == {0} + + def test_aggregate_capture_reference_filters_incomplete_sweeps(): sweeps = load_capture_sweeps(str(SAMPLE_BG), fancy=False, logscale=False) vector, summary = aggregate_capture_reference(sweeps, channel=0, method="median", path=str(SAMPLE_BG)) diff --git a/tests/test_ring_buffer_fft_axis.py b/tests/test_ring_buffer_fft_axis.py index 499deec..f78a42f 100644 --- a/tests/test_ring_buffer_fft_axis.py +++ b/tests/test_ring_buffer_fft_axis.py @@ -1,5 +1,6 @@ import numpy as np +from rfg_adc_plotter.processing.fourier import compute_ifft_profile_from_sweep from rfg_adc_plotter.state.ring_buffer import RingBuffer @@ -8,6 +9,7 @@ def test_ring_buffer_allocates_fft_buffers_from_first_push(): ring.ensure_init(64) sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32) + depth_expected, vals_expected = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos") ring.push(sweep) assert ring.ring_fft is not None @@ -16,14 +18,7 @@ def test_ring_buffer_allocates_fft_buffers_from_first_push(): assert ring.fft_bins == ring.ring_fft.shape[1] assert ring.fft_bins == ring.fft_depth_axis_m.size assert ring.fft_bins == ring.last_fft_vals.size - assert ring.last_fft_third_axes_m != (None, None, None) - assert ring.last_fft_third_vals != (None, None, None) - for axis, vals in zip(ring.last_fft_third_axes_m, ring.last_fft_third_vals): - assert axis is not None - assert vals is not None - assert axis.dtype == np.float32 - assert vals.dtype == np.float32 - assert axis.size == vals.size + assert ring.fft_bins == min(depth_expected.size, vals_expected.size) # Legacy alias kept for compatibility with existing GUI code paths. assert ring.fft_time_axis is ring.fft_depth_axis_m @@ -56,8 +51,6 @@ def test_ring_buffer_mode_switch_resets_fft_buffers_only(): assert ring.ring is not None assert ring.ring_fft is not None raw_before = ring.ring.copy() - assert ring.last_fft_third_axes_m != (None, None, None) - assert ring.last_fft_third_vals != (None, None, None) changed = ring.set_fft_complex_mode("diff") assert changed is True @@ -67,35 +60,22 @@ def test_ring_buffer_mode_switch_resets_fft_buffers_only(): assert ring.ring_fft is None assert ring.fft_depth_axis_m is None assert ring.last_fft_vals is None - assert ring.last_fft_third_axes_m == (None, None, None) - assert ring.last_fft_third_vals == (None, None, None) assert ring.fft_bins == 0 ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32)) assert ring.ring_fft is not None assert ring.fft_depth_axis_m is not None assert ring.last_fft_vals is not None - assert ring.last_fft_third_axes_m != (None, None, None) - assert ring.last_fft_third_vals != (None, None, None) - for axis, vals in zip(ring.last_fft_third_axes_m, ring.last_fft_third_vals): - assert axis is not None - assert vals is not None - assert axis.dtype == np.float32 - assert vals.dtype == np.float32 - assert axis.size == vals.size -def test_ring_buffer_short_sweeps_keep_third_profiles_well_formed(): +def test_ring_buffer_short_sweeps_keep_fft_profile_well_formed(): for n in (1, 2, 3): ring = RingBuffer(max_sweeps=4) ring.ensure_init(n) ring.push(np.linspace(-1.0, 1.0, n, dtype=np.float32)) - assert ring.last_fft_third_axes_m != (None, None, None) - assert ring.last_fft_third_vals != (None, None, None) - for axis, vals in zip(ring.last_fft_third_axes_m, ring.last_fft_third_vals): - assert axis is not None - assert vals is not None - assert axis.dtype == np.float32 - assert vals.dtype == np.float32 - assert axis.size == vals.size + assert ring.fft_depth_axis_m is not None + assert ring.last_fft_vals is not None + assert ring.fft_depth_axis_m.dtype == np.float32 + assert ring.last_fft_vals.dtype == np.float32 + assert ring.fft_depth_axis_m.size == ring.last_fft_vals.size diff --git a/tests/test_sweep_parser_core_binary_protocols.py b/tests/test_sweep_parser_core_binary_protocols.py new file mode 100644 index 0000000..3607a8b --- /dev/null +++ b/tests/test_sweep_parser_core_binary_protocols.py @@ -0,0 +1,110 @@ +import math + +from rfg_adc_plotter.io.sweep_parser_core import BinaryRecordStreamParser + + +def _u16le(word: int) -> bytes: + w = int(word) & 0xFFFF + return bytes((w & 0xFF, (w >> 8) & 0xFF)) + + +def _pack_signed_words_be(value: int, words: int) -> list[int]: + bits = 16 * int(words) + v = int(value) + if v < 0: + v = (1 << bits) + v + out: list[int] = [] + for i in range(words): + shift = (words - 1 - i) * 16 + out.append((v >> shift) & 0xFFFF) + return out + + +def _pack_legacy_start(ch: int) -> bytes: + return b"\xff\xff" * 3 + bytes((0x0A, int(ch) & 0xFF)) + + +def _pack_legacy_point(ch: int, step: int, value_i32: int) -> bytes: + v = int(value_i32) & 0xFFFF_FFFF + return b"".join( + [ + _u16le(step), + _u16le((v >> 16) & 0xFFFF), + _u16le(v & 0xFFFF), + bytes((0x0A, int(ch) & 0xFF)), + ] + ) + + +def _pack_log_start(ch: int) -> bytes: + return b"\xff\xff" * 5 + bytes((0x0A, int(ch) & 0xFF)) + + +def _pack_log_point(step: int, avg1: int, avg2: int, pair_words: int, ch: int = 0) -> bytes: + words = [int(step) & 0xFFFF] + words.extend(_pack_signed_words_be(avg1, pair_words)) + words.extend(_pack_signed_words_be(avg2, pair_words)) + words.append(((int(ch) & 0xFF) << 8) | 0x000A) + return b"".join(_u16le(w) for w in words) + + +def _log_pair_to_linear(avg1: int, avg2: int) -> float: + exp1 = max(-300.0, min(300.0, float(avg1) * 0.001)) + exp2 = max(-300.0, min(300.0, float(avg2) * 0.001)) + return (math.pow(10.0, exp1) - math.pow(10.0, exp2)) * 1000.0 + + +def test_binary_parser_parses_legacy_8_byte_records(): + parser = BinaryRecordStreamParser() + stream = b"".join( + [ + _pack_legacy_start(3), + _pack_legacy_point(3, 1, -2), + _pack_legacy_point(3, 2, 123456), + ] + ) + + events = [] + events.extend(parser.feed(stream[:5])) + events.extend(parser.feed(stream[5:17])) + events.extend(parser.feed(stream[17:])) + + assert events[0] == ("start", 3) + assert events[1] == ("point", 3, 1, -2.0) + assert events[2] == ("point", 3, 2, 123456.0) + + +def test_binary_parser_parses_logdetector_32bit_pair_records(): + parser = BinaryRecordStreamParser() + stream = b"".join( + [ + _pack_log_start(0), + _pack_log_point(1, 1500, 700, pair_words=2, ch=0), + _pack_log_point(2, 1510, 710, pair_words=2, ch=0), + ] + ) + + events = parser.feed(stream) + assert events[0] == ("start", 0) + assert events[1][0:3] == ("point", 0, 1) + assert events[2][0:3] == ("point", 0, 2) + assert abs(float(events[1][3]) - _log_pair_to_linear(1500, 700)) < 1e-6 + assert abs(float(events[2][3]) - _log_pair_to_linear(1510, 710)) < 1e-6 + + +def test_binary_parser_parses_logdetector_128bit_pair_records(): + parser = BinaryRecordStreamParser() + stream = b"".join( + [ + _pack_log_start(5), + _pack_log_point(7, 1600, 800, pair_words=8, ch=5), + _pack_log_point(8, 1610, 810, pair_words=8, ch=5), + ] + ) + + events = parser.feed(stream) + assert events[0] == ("start", 5) + assert events[1][0:3] == ("point", 5, 7) + assert events[2][0:3] == ("point", 5, 8) + assert abs(float(events[1][3]) - _log_pair_to_linear(1600, 800)) < 1e-6 + assert abs(float(events[2][3]) - _log_pair_to_linear(1610, 810)) < 1e-6