diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index 9b9321e..fab8927 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -31,6 +31,7 @@ from typing import Optional, Tuple import numpy as np WF_WIDTH = 1000 # максимальное число точек в ряду водопада +FFT_LEN = 1024 # длина БПФ для спектра/водопада спектров def try_open_pyserial(path: str, baud: int, timeout: float): @@ -383,7 +384,8 @@ def main(): reader.start() # Графика - fig, (ax_line, ax_img) = plt.subplots(1, 2, figsize=(12, 6)) + fig, axs = plt.subplots(2, 2, figsize=(12, 8)) + (ax_line, ax_img), (ax_fft, ax_spec) = axs fig.canvas.manager.set_window_title(args.title) if hasattr(fig.canvas.manager, "set_window_title") else None fig.tight_layout() @@ -395,6 +397,11 @@ def main(): ring = None # type: Optional[np.ndarray] head = 0 y_min, y_max = None, None + # FFT состояние + fft_bins = FFT_LEN // 2 + 1 + ring_fft = None # type: Optional[np.ndarray] + y_min_fft, y_max_fft = None, None + freq_shared: Optional[np.ndarray] = None # Линейный график последнего свипа line_obj, = ax_line.plot([], [], lw=1) @@ -402,14 +409,22 @@ def main(): ax_line.set_xlabel("X") ax_line.set_ylabel("Y") - fixed_ylim: Optional[Tuple[float, float]] = None + # Линейный график спектра текущего свипа + fft_line_obj, = ax_fft.plot([], [], lw=1) + ax_fft.set_title("Спектр (|FFT|, дБ)") + ax_fft.set_xlabel("Бин") + ax_fft.set_ylabel("Амплитуда, дБ") + + # Фиксированный диапазон по Y для последнего свипа: 0..4095 (12-бит без знака) + fixed_ylim: Optional[Tuple[float, float]] = (0.0, float(2 ** 12 - 1)) + # CLI переопределение при необходимости if args.ylim: try: y0, y1 = args.ylim.split(",") fixed_ylim = (float(y0), float(y1)) - ax_line.set_ylim(fixed_ylim) except Exception: sys.stderr.write("[warn] Некорректный формат --ylim, игнорирую. Ожидалось min,max\n") + ax_line.set_ylim(fixed_ylim) # Водопад (будет инициализирован при первом свипе) img_obj = ax_img.imshow( @@ -423,13 +438,25 @@ def main(): ax_img.set_xlabel("X") ax_img.set_ylabel("Номер свипа (время →)") + # Водопад спектров + img_fft_obj = ax_spec.imshow( + np.zeros((1, 1), dtype=np.float32), + aspect="auto", + interpolation="nearest", + origin="upper", + cmap=args.cmap, + ) + ax_spec.set_title("Водопад спектров (дБ)") + ax_spec.set_xlabel("Бин") + ax_spec.set_ylabel("Номер свипа (время →)") + # Для контроля частоты обновления max_fps = max(1.0, float(args.max_fps)) interval_ms = int(1000.0 / max_fps) frames_since_ylim_update = 0 def ensure_buffer(_w: int): - nonlocal ring, width, head, x_shared + nonlocal ring, width, head, x_shared, ring_fft, freq_shared if ring is not None: return width = WF_WIDTH @@ -441,9 +468,16 @@ def main(): img_obj.set_extent((0, width - 1 if width > 0 else 1, 0, max_sweeps - 1)) ax_img.set_xlim(0, max(1, width - 1)) ax_img.set_ylim(max_sweeps - 1, 0) + # FFT буферы + ring_fft = np.full((max_sweeps, fft_bins), np.nan, dtype=np.float32) + img_fft_obj.set_data(ring_fft) + img_fft_obj.set_extent((0, fft_bins - 1, 0, max_sweeps - 1)) + ax_spec.set_xlim(0, max(1, fft_bins - 1)) + ax_spec.set_ylim(max_sweeps - 1, 0) + freq_shared = np.arange(fft_bins, dtype=np.int32) def push_sweep(s: np.ndarray): - nonlocal ring, head, y_min, y_max + nonlocal ring, head, y_min, y_max, ring_fft, y_min_fft, y_max_fft if s is None or s.size == 0 or ring is None: return # Нормализуем длину до фиксированной ширины @@ -460,6 +494,38 @@ def main(): y_min = float(sv_min) if y_max is None or (not np.isnan(sv_max) and sv_max > y_max): y_max = float(sv_max) + # FFT строка (дБ) + if ring_fft is not None: + bins = ring_fft.shape[1] + # Подготовка входа FFT_LEN, замена NaN на 0 + take_fft = min(int(s.size), FFT_LEN) + if take_fft <= 0: + fft_row = np.full((bins,), np.nan, dtype=np.float32) + else: + fft_in = np.zeros((FFT_LEN,), dtype=np.float32) + seg = s[:take_fft] + if isinstance(seg, np.ndarray): + seg = np.nan_to_num(seg, nan=0.0).astype(np.float32, copy=False) + else: + seg = np.asarray(seg, dtype=np.float32) + seg = np.nan_to_num(seg, nan=0.0) + # Окно Хэннинга + win = np.hanning(take_fft).astype(np.float32) + fft_in[:take_fft] = seg * win + spec = np.fft.rfft(fft_in) + mag = np.abs(spec).astype(np.float32) + fft_row = 20.0 * np.log10(mag + 1e-9) + if fft_row.shape[0] != bins: + # rfft длиной FFT_LEN даёт bins == FFT_LEN//2+1 + fft_row = fft_row[:bins] + ring_fft[(head - 1) % ring_fft.shape[0], :] = fft_row + # Экстремумы для цветовой шкалы + fr_min = np.nanmin(fft_row) + fr_max = np.nanmax(fft_row) + if y_min_fft is None or (not np.isnan(fr_min) and fr_min < y_min_fft): + y_min_fft = float(fr_min) + if y_max_fft is None or (not np.isnan(fr_max) and fr_max > y_max_fft): + y_max_fft = float(fr_max) def drain_queue(): nonlocal current_sweep @@ -483,6 +549,13 @@ def main(): return ring return np.roll(ring, -head, axis=0) + def make_display_ring_fft(): + if ring_fft is None: + return np.zeros((1, 1), dtype=np.float32) + if head == 0: + return ring_fft + return np.roll(ring_fft, -head, axis=0) + def update(_frame): nonlocal frames_since_ylim_update changed = drain_queue() > 0 @@ -496,16 +569,26 @@ def main(): line_obj.set_data(xs, current_sweep) # Лимиты по X постоянные под текущую ширину ax_line.set_xlim(0, max(1, current_sweep.size - 1)) - # Y-лимиты: фиксированные либо периодическая автоподстройка - if fixed_ylim is None: - frames_since_ylim_update += 1 - if frames_since_ylim_update >= 3: # реже трогаем ось для скорости - y0 = np.nanmin(current_sweep) - y1 = np.nanmax(current_sweep) - if np.isfinite(y0) and np.isfinite(y1): - margin = 0.05 * max(1.0, float(y1 - y0)) - ax_line.set_ylim(y0 - margin, y1 + margin) - frames_since_ylim_update = 0 + # Y-лимиты фиксированы (±2048) или из --ylim; не автоподстраиваем + + # Обновление спектра текущего свипа + take_fft = min(int(current_sweep.size), FFT_LEN) + if take_fft > 0 and freq_shared is not None: + fft_in = np.zeros((FFT_LEN,), dtype=np.float32) + seg = np.nan_to_num(current_sweep[:take_fft], nan=0.0).astype(np.float32, copy=False) + win = np.hanning(take_fft).astype(np.float32) + fft_in[:take_fft] = seg * win + spec = np.fft.rfft(fft_in) + mag = np.abs(spec).astype(np.float32) + fft_vals = 20.0 * np.log10(mag + 1e-9) + xs_fft = freq_shared + if fft_vals.size > xs_fft.size: + fft_vals = fft_vals[: xs_fft.size] + fft_line_obj.set_data(xs_fft[: fft_vals.size], fft_vals) + # Авто-диапазон по Y для спектра + if np.isfinite(np.nanmin(fft_vals)) and np.isfinite(np.nanmax(fft_vals)): + ax_fft.set_xlim(0, max(1, xs_fft.size - 1)) + ax_fft.set_ylim(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals))) # Обновление водопада if changed and ring is not None: @@ -516,8 +599,16 @@ def main(): if y_min != y_max: img_obj.set_clim(vmin=y_min, vmax=y_max) + # Обновление водопада спектров + if changed and ring_fft is not None: + disp_fft = make_display_ring_fft() + img_fft_obj.set_data(disp_fft) + if y_min_fft is not None and y_max_fft is not None and np.isfinite(y_min_fft) and np.isfinite(y_max_fft): + if y_min_fft != y_max_fft: + img_fft_obj.set_clim(vmin=y_min_fft, vmax=y_max_fft) + # Возвращаем обновлённые артисты - return (line_obj, img_obj) + return (line_obj, img_obj, fft_line_obj, img_fft_obj) ani = FuncAnimation(fig, update, interval=interval_ms, blit=False) @@ -559,14 +650,14 @@ def run_pyqtgraph(args): win = pg.GraphicsLayoutWidget(show=True, title=args.title) win.resize(1200, 600) - # Плот последнего свипа (слева) + # Плот последнего свипа (слева-сверху) p_line = win.addPlot(row=0, col=0, title="Последний свип") p_line.showGrid(x=True, y=True, alpha=0.3) curve = p_line.plot(pen=pg.mkPen((80, 120, 255), width=1)) p_line.setLabel("bottom", "X") p_line.setLabel("left", "Y") - # Водопад (справа) + # Водопад (справа-сверху) p_img = win.addPlot(row=0, col=1, title="Водопад (последние свипы)") p_img.invertY(True) # 0 сверху, новые снизу p_img.showGrid(x=False, y=False) @@ -575,6 +666,22 @@ def run_pyqtgraph(args): img = pg.ImageItem() p_img.addItem(img) + # Спектр (слева-снизу) + p_fft = win.addPlot(row=1, col=0, title="Спектр (|FFT|, дБ)") + p_fft.showGrid(x=True, y=True, alpha=0.3) + curve_fft = p_fft.plot(pen=pg.mkPen((255, 120, 80), width=1)) + p_fft.setLabel("bottom", "Бин") + p_fft.setLabel("left", "Амплитуда, дБ") + + # Водопад спектров (справа-снизу) + p_spec = win.addPlot(row=1, col=1, title="Водопад спектров (дБ)") + p_spec.invertY(True) + p_spec.showGrid(x=False, y=False) + p_spec.setLabel("bottom", "Бин") + p_spec.setLabel("left", "Номер свипа (время →)") + img_fft = pg.ImageItem() + p_spec.addItem(img_fft) + # Состояние ring: Optional[np.ndarray] = None head = 0 @@ -582,17 +689,23 @@ def run_pyqtgraph(args): x_shared: Optional[np.ndarray] = None current_sweep: Optional[np.ndarray] = None y_min, y_max = None, None - fixed_ylim: Optional[Tuple[float, float]] = None + # Для спектров + fft_bins = FFT_LEN // 2 + 1 + ring_fft: Optional[np.ndarray] = None + freq_shared: Optional[np.ndarray] = None + y_min_fft, y_max_fft = None, None + # Фиксированный диапазон по Y: 0..4095 (можно переопределить --ylim) + fixed_ylim: Optional[Tuple[float, float]] = (0.0, float(2 ** 12 - 1)) if args.ylim: try: y0, y1 = args.ylim.split(",") fixed_ylim = (float(y0), float(y1)) - p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0) except Exception: pass + p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0) def ensure_buffer(_w: int): - nonlocal ring, head, width, x_shared + nonlocal ring, head, width, x_shared, ring_fft, freq_shared if ring is not None: return width = WF_WIDTH @@ -602,9 +715,15 @@ def run_pyqtgraph(args): img.setImage(ring, autoLevels=False) p_img.setRange(xRange=(0, max(1, width - 1)), yRange=(0, max_sweeps - 1), padding=0) p_line.setXRange(0, max(1, width - 1), padding=0) + # FFT + ring_fft = np.full((max_sweeps, fft_bins), np.nan, dtype=np.float32) + img_fft.setImage(ring_fft, autoLevels=False) + p_spec.setRange(xRange=(0, max(1, fft_bins - 1)), yRange=(0, max_sweeps - 1), padding=0) + p_fft.setXRange(0, max(1, fft_bins - 1), padding=0) + freq_shared = np.arange(fft_bins, dtype=np.int32) def push_sweep(s: np.ndarray): - nonlocal ring, head, y_min, y_max + nonlocal ring, head, y_min, y_max, ring_fft, y_min_fft, y_max_fft if s is None or s.size == 0 or ring is None: return w = ring.shape[1] @@ -620,6 +739,29 @@ def run_pyqtgraph(args): y_min = float(sv_min) if y_max is None or (not np.isnan(sv_max) and sv_max > y_max): y_max = float(sv_max) + # FFT строка (дБ) + if ring_fft is not None: + bins = ring_fft.shape[1] + take_fft = min(int(s.size), FFT_LEN) + if take_fft > 0: + fft_in = np.zeros((FFT_LEN,), dtype=np.float32) + seg = np.nan_to_num(s[:take_fft], nan=0.0).astype(np.float32, copy=False) + win = np.hanning(take_fft).astype(np.float32) + fft_in[:take_fft] = seg * win + spec = np.fft.rfft(fft_in) + mag = np.abs(spec).astype(np.float32) + fft_row = 20.0 * np.log10(mag + 1e-9) + if fft_row.shape[0] != bins: + fft_row = fft_row[:bins] + else: + fft_row = np.full((bins,), np.nan, dtype=np.float32) + ring_fft[(head - 1) % ring_fft.shape[0], :] = fft_row + fr_min = np.nanmin(fft_row) + fr_max = np.nanmax(fft_row) + if y_min_fft is None or (not np.isnan(fr_min) and fr_min < y_min_fft): + y_min_fft = float(fr_min) + if y_max_fft is None or (not np.isnan(fr_max) and fr_max > y_max_fft): + y_max_fft = float(fr_max) def drain_queue(): nonlocal current_sweep @@ -659,6 +801,22 @@ def run_pyqtgraph(args): margin = 0.05 * max(1.0, (y1 - y0)) p_line.setYRange(y0 - margin, y1 + margin, padding=0) + # Обновим спектр + take_fft = min(int(current_sweep.size), FFT_LEN) + if take_fft > 0 and freq_shared is not None: + fft_in = np.zeros((FFT_LEN,), dtype=np.float32) + seg = np.nan_to_num(current_sweep[:take_fft], nan=0.0).astype(np.float32, copy=False) + win = np.hanning(take_fft).astype(np.float32) + fft_in[:take_fft] = seg * win + spec = np.fft.rfft(fft_in) + mag = np.abs(spec).astype(np.float32) + fft_vals = 20.0 * np.log10(mag + 1e-9) + xs_fft = freq_shared + if fft_vals.size > xs_fft.size: + fft_vals = fft_vals[: xs_fft.size] + curve_fft.setData(xs_fft[: fft_vals.size], fft_vals) + p_fft.setYRange(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)), padding=0) + if changed and ring is not None: disp = ring if head == 0 else np.roll(ring, -head, axis=0) if y_min is not None and y_max is not None and y_min != y_max and np.isfinite(y_min) and np.isfinite(y_max): @@ -666,6 +824,13 @@ def run_pyqtgraph(args): else: img.setImage(disp, autoLevels=False) + if changed and ring_fft is not None: + disp_fft = ring_fft if head == 0 else np.roll(ring_fft, -head, axis=0) + if y_min_fft is not None and y_max_fft is not None and y_min_fft != y_max_fft and np.isfinite(y_min_fft) and np.isfinite(y_max_fft): + img_fft.setImage(disp_fft, autoLevels=False, levels=(y_min_fft, y_max_fft)) + else: + img_fft.setImage(disp_fft, autoLevels=False) + timer = pg.QtCore.QTimer() timer.timeout.connect(update) timer.start(interval_ms)