From 877a8a6cd0a200426ce944f920a9320617a83a76 Mon Sep 17 00:00:00 2001 From: Theodor Chikin Date: Mon, 9 Feb 2026 16:38:45 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B2=D1=8B=D1=87=D0=B8=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D1=80=D0=B5=D0=B4=D0=BD=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B5=D0=BA=D1=82=D1=80=D0=B0=20=D0=B7=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=B5=20N=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4=20=D0=B2=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B0=D0=B4=D0=B5=20=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=20CLI=20--spec-mean-sec=20?= =?UTF-8?q?(float,=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8E=200.0)=20=20=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B8=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RFG_ADC_dataplotter.py | 50 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index 3a4c964..d10acc9 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -478,6 +478,15 @@ def main(): "Напр. 2,98. 'off' — отключить" ), ) + parser.add_argument( + "--spec-mean-sec", + type=float, + default=0.0, + help=( + "Вычитание среднего по каждой частоте за последние N секунд " + "в водопаде спектров (0 — отключить)" + ), + ) parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна") parser.add_argument( "--fancy", @@ -548,6 +557,7 @@ def main(): freq_shared: Optional[np.ndarray] = None # Параметры контраста водопада спектров spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None)) + spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) # Ползунки управления Y для B-scan и контрастом ymin_slider = None ymax_slider = None @@ -780,6 +790,24 @@ def main(): base_t = ring_time if head == 0 else np.roll(ring_time, -head) return base_t + def _subtract_recent_mean_fft(disp_fft: np.ndarray) -> np.ndarray: + """Вычесть среднее по каждой частоте за последние spec_mean_sec секунд.""" + if spec_mean_sec <= 0.0: + return disp_fft + disp_times = make_display_times() + if disp_times is None: + return disp_fft + now_t = time.time() + mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec)) + if not np.any(mask): + return disp_fft + try: + mean_spec = np.nanmean(disp_fft[:, mask], axis=1) + except Exception: + return disp_fft + mean_spec = np.nan_to_num(mean_spec, nan=0.0) + return disp_fft - mean_spec[:, None] + def make_display_ring_fft(): if ring_fft is None: return np.zeros((1, 1), dtype=np.float32) @@ -847,6 +875,7 @@ def main(): # Обновление водопада спектров if changed and ring_fft is not None: disp_fft = make_display_ring_fft() + disp_fft = _subtract_recent_mean_fft(disp_fft) # Новые данные справа: без реверса img_fft_obj.set_data(disp_fft) # Подписи времени не обновляем динамически (оставляем авто-тики) @@ -971,6 +1000,7 @@ def run_pyqtgraph(args): # Состояние ring: Optional[np.ndarray] = None + ring_time: Optional[np.ndarray] = None head = 0 width: Optional[int] = None x_shared: Optional[np.ndarray] = None @@ -984,6 +1014,7 @@ def run_pyqtgraph(args): y_min_fft, y_max_fft = None, None # Параметры контраста водопада спектров (процентильная обрезка) spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None)) + spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) # Диапазон по Y: авто по умолчанию (поддерживает отрицательные значения) fixed_ylim: Optional[Tuple[float, float]] = None if args.ylim: @@ -996,12 +1027,13 @@ def run_pyqtgraph(args): p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0) def ensure_buffer(_w: int): - nonlocal ring, head, width, x_shared, ring_fft, freq_shared + nonlocal ring, ring_time, head, width, x_shared, ring_fft, freq_shared if ring is not None: return width = WF_WIDTH x_shared = np.arange(width, dtype=np.int32) ring = np.full((max_sweeps, width), np.nan, dtype=np.float32) + ring_time = np.full((max_sweeps,), np.nan, dtype=np.float64) head = 0 # Водопад: время по оси X, X по оси Y img.setImage(ring.T, autoLevels=False) @@ -1046,7 +1078,7 @@ def run_pyqtgraph(args): return (vmin, vmax) def push_sweep(s: np.ndarray): - nonlocal ring, head, ring_fft, y_min_fft, y_max_fft + nonlocal ring, ring_time, head, 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] @@ -1054,6 +1086,8 @@ def run_pyqtgraph(args): take = min(w, s.size) row[:take] = s[:take] ring[head, :] = row + if ring_time is not None: + ring_time[head] = time.time() head = (head + 1) % ring.shape[0] # FFT строка (дБ) if ring_fft is not None: @@ -1152,6 +1186,18 @@ def run_pyqtgraph(args): if changed and ring_fft is not None: disp_fft = ring_fft if head == 0 else np.roll(ring_fft, -head, axis=0) disp_fft = disp_fft.T[:, ::-1] + if spec_mean_sec > 0.0 and ring_time is not None: + disp_times = ring_time if head == 0 else np.roll(ring_time, -head) + disp_times = disp_times[::-1] + now_t = time.time() + mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec)) + if np.any(mask): + try: + mean_spec = np.nanmean(disp_fft[:, mask], axis=1) + mean_spec = np.nan_to_num(mean_spec, nan=0.0) + disp_fft = disp_fft - mean_spec[:, None] + except Exception: + pass # Автодиапазон по среднему спектру за видимый интервал (как в хорошей версии) levels = None try: From 869d5baebce3698a9b4b7a6df272a799bb556a0f Mon Sep 17 00:00:00 2001 From: Theodor Chikin Date: Mon, 9 Feb 2026 17:02:16 +0300 Subject: [PATCH 2/3] feat: new data format: 's0 0181 +000019' where s0 stands for channel 0. Chan number is shown near to sweep --- RFG_ADC_dataplotter.py | 92 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index d10acc9..4efe717 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -4,7 +4,7 @@ Формат строк: - "Sweep_start" — начало нового свипа (предыдущий считается завершённым) - - "s X Y" — точка (индекс X, значение Y), все целые со знаком + - "s CH X Y" — точка (номер канала, индекс X, значение Y), все целые со знаком Отрисовываются два графика: - Левый: последний полученный свип (Y vs X) @@ -287,9 +287,11 @@ class SweepReader(threading.Thread): self._last_sweep_ts: Optional[float] = None self._n_valid_hist = deque() - def _finalize_current(self, xs, ys): + def _finalize_current(self, xs, ys, channels: Optional[set[int]]): if not xs: return + ch_list = sorted(channels) if channels else [0] + ch_primary = ch_list[0] if ch_list else 0 max_x = max(xs) width = max_x + 1 self._max_width = max(self._max_width, width) @@ -340,6 +342,10 @@ class SweepReader(threading.Thread): # Метрики для статусной строки (вид словаря: переменная -> значение) self._sweep_idx += 1 + if len(ch_list) > 1: + sys.stderr.write( + f"[warn] Sweep {self._sweep_idx}: изменялся номер канала: {ch_list}\n" + ) now = time.time() if self._last_sweep_ts is None: dt_ms = float("nan") @@ -363,6 +369,8 @@ class SweepReader(threading.Thread): vmin = vmax = mean = std = float("nan") info: SweepInfo = { "sweep": self._sweep_idx, + "ch": ch_primary, + "chs": ch_list, "n_valid": n_valid, "min": vmin, "max": vmax, @@ -388,6 +396,8 @@ class SweepReader(threading.Thread): # Состояние текущего свипа xs: list[int] = [] ys: list[int] = [] + cur_channel: Optional[int] = None + cur_channels: set[int] = set() try: self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0) @@ -422,20 +432,37 @@ class SweepReader(threading.Thread): continue if line.startswith(b"Sweep_start"): - self._finalize_current(xs, ys) + self._finalize_current(xs, ys, cur_channels) xs.clear() ys.clear() + cur_channel = None + cur_channels.clear() continue - # s X Y (оба целые со знаком). Разделяем по любым пробелам/табам. + # sCH X Y или s CH X Y (все целые со знаком). Разделяем по любым пробелам/табам. if len(line) >= 3: parts = line.split() - if len(parts) >= 3 and parts[0].lower() == b"s": + if len(parts) >= 3 and (parts[0].lower() == b"s" or parts[0].lower().startswith(b"s")): try: - x = int(parts[1], 10) - y = int(parts[2], 10) # поддержка знака: "+…" и "-…" + if parts[0].lower() == b"s": + if len(parts) >= 4: + ch = int(parts[1], 10) + x = int(parts[2], 10) + y = int(parts[3], 10) # поддержка знака: "+…" и "-…" + else: + ch = 0 + x = int(parts[1], 10) + y = int(parts[2], 10) # поддержка знака: "+…" и "-…" + else: + # формат вида "s0" + ch = int(parts[0][1:], 10) + x = int(parts[1], 10) + y = int(parts[2], 10) # поддержка знака: "+…" и "-…" except Exception: continue + if cur_channel is None: + cur_channel = ch + cur_channels.add(ch) xs.append(x) ys.append(y) @@ -445,7 +472,7 @@ class SweepReader(threading.Thread): finally: try: # Завершаем оставшийся свип - self._finalize_current(xs, ys) + self._finalize_current(xs, ys, cur_channels) except Exception: pass try: @@ -579,6 +606,16 @@ def main(): ax_line.set_title("Сырые данные", pad=1) ax_line.set_xlabel("F") ax_line.set_ylabel("") + channel_text = ax_line.text( + 0.98, + 0.98, + "", + transform=ax_line.transAxes, + ha="right", + va="top", + fontsize=9, + family="monospace", + ) # Линейный график спектра текущего свипа fft_line_obj, = ax_fft.plot([], [], lw=1) @@ -910,9 +947,24 @@ def main(): if changed and current_info: status_text.set_text(_format_status_kv(current_info)) + chs = current_info.get("chs") if isinstance(current_info, dict) else None + if chs is None: + chs = current_info.get("ch") if isinstance(current_info, dict) else None + if chs is None: + channel_text.set_text("") + else: + try: + if isinstance(chs, (list, tuple, set)): + ch_list = sorted(int(v) for v in chs) + ch_text_val = ", ".join(str(v) for v in ch_list) + else: + ch_text_val = str(int(chs)) + channel_text.set_text(f"chs {ch_text_val}") + except Exception: + channel_text.set_text(f"chs {chs}") # Возвращаем обновлённые артисты - return (line_obj, img_obj, fft_line_obj, img_fft_obj, status_text) + return (line_obj, img_obj, fft_line_obj, img_fft_obj, status_text, channel_text) ani = FuncAnimation(fig, update, interval=interval_ms, blit=False) @@ -960,6 +1012,9 @@ def run_pyqtgraph(args): curve = p_line.plot(pen=pg.mkPen((80, 120, 255), width=1)) p_line.setLabel("bottom", "X") p_line.setLabel("left", "Y") + ch_text = pg.TextItem("", anchor=(1, 1)) + ch_text.setZValue(10) + p_line.addItem(ch_text) # Водопад (справа-сверху) p_img = win.addPlot(row=0, col=1, title="Сырые данные водопад") @@ -1182,6 +1237,25 @@ def run_pyqtgraph(args): status.setText(_format_status_kv(current_info)) except Exception: pass + try: + chs = current_info.get("chs") if isinstance(current_info, dict) else None + if chs is None: + chs = current_info.get("ch") if isinstance(current_info, dict) else None + if chs is None: + ch_text.setText("") + else: + if isinstance(chs, (list, tuple, set)): + ch_list = sorted(int(v) for v in chs) + ch_text_val = ", ".join(str(v) for v in ch_list) + else: + ch_text_val = str(int(chs)) + ch_text.setText(f"chs {ch_text_val}") + (x0, x1), (y0, y1) = p_line.viewRange() + dx = 0.01 * max(1.0, float(x1 - x0)) + dy = 0.01 * max(1.0, float(y1 - y0)) + ch_text.setPos(float(x1 - dx), float(y1 - dy)) + except Exception: + pass if changed and ring_fft is not None: disp_fft = ring_fft if head == 0 else np.roll(ring_fft, -head, axis=0) From 3074859793a4fb88cc6e8fa1162bc1fe802fd2aa Mon Sep 17 00:00:00 2001 From: Theodor Chikin Date: Mon, 9 Feb 2026 20:55:09 +0300 Subject: [PATCH 3/3] implemented calibration: last s0 sweep stored and used as calibration val. If checkbox []calibrate is active -- normalised val used for feature processing --- RFG_ADC_dataplotter.py | 193 ++++++++++++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 33 deletions(-) diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index 4efe717..b3ab06d 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -38,7 +38,7 @@ FFT_LEN = 1024 # длина БПФ для спектра/водопада сп DATA_INVERSION_THRASHOLD = 10.0 Number = Union[int, float] -SweepInfo = Dict[str, Number] +SweepInfo = Dict[str, Any] SweepPacket = Tuple[np.ndarray, SweepInfo] @@ -338,7 +338,7 @@ class SweepReader(threading.Thread): sweep *= -1.0 except Exception: pass - sweep -= float(np.nanmean(sweep)) + #sweep -= float(np.nanmean(sweep)) # Метрики для статусной строки (вид словаря: переменная -> значение) self._sweep_idx += 1 @@ -549,7 +549,7 @@ def main(): import matplotlib import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation - from matplotlib.widgets import Slider + from matplotlib.widgets import Slider, CheckButtons except Exception as e: sys.stderr.write(f"[error] Нужны matplotlib и ее зависимости: {e}\n") sys.exit(1) @@ -568,7 +568,9 @@ def main(): fig.subplots_adjust(wspace=0.25, hspace=0.35, left=0.07, right=0.90, top=0.92, bottom=0.08) # Состояние для отображения - current_sweep: Optional[np.ndarray] = None + current_sweep_raw: Optional[np.ndarray] = None + current_sweep_norm: Optional[np.ndarray] = None + last_calib_sweep: Optional[np.ndarray] = None current_info: Optional[SweepInfo] = None x_shared: Optional[np.ndarray] = None width: Optional[int] = None @@ -589,6 +591,8 @@ def main(): ymin_slider = None ymax_slider = None contrast_slider = None + calib_enabled = False + cb = None # Статусная строка (внизу окна) status_text = fig.text( @@ -602,7 +606,9 @@ def main(): ) # Линейный график последнего свипа - line_obj, = ax_line.plot([], [], lw=1) + line_obj, = ax_line.plot([], [], lw=1, color="tab:blue") + line_calib_obj, = ax_line.plot([], [], lw=1, color="tab:red") + line_norm_obj, = ax_line.plot([], [], lw=1, color="tab:green") ax_line.set_title("Сырые данные", pad=1) ax_line.set_xlabel("F") ax_line.set_ylabel("") @@ -668,14 +674,37 @@ def main(): ax_spec.tick_params(axis="x", labelbottom=False) except Exception: pass + def _normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: + w = min(raw.size, calib.size) + if w <= 0: + return raw + out = np.full_like(raw, np.nan, dtype=np.float32) + with np.errstate(divide="ignore", invalid="ignore"): + out[:w] = raw[:w] / calib[:w] + out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan) + return out + + def _set_calib_enabled(): + nonlocal calib_enabled, current_sweep_norm + try: + calib_enabled = bool(cb.get_status()[0]) if cb is not None else False + except Exception: + calib_enabled = False + if calib_enabled and current_sweep_raw is not None and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(current_sweep_raw, last_calib_sweep) + else: + current_sweep_norm = None + # Слайдеры для управления осью Y B-scan (мин/макс) и контрастом try: ax_smin = fig.add_axes([0.92, 0.55, 0.02, 0.35]) ax_smax = fig.add_axes([0.95, 0.55, 0.02, 0.35]) ax_sctr = fig.add_axes([0.98, 0.55, 0.02, 0.35]) + ax_cb = fig.add_axes([0.92, 0.45, 0.08, 0.08]) ymin_slider = Slider(ax_smin, "Y min", 0, max(1, fft_bins - 1), valinit=0, valstep=1, orientation="vertical") ymax_slider = Slider(ax_smax, "Y max", 0, max(1, fft_bins - 1), valinit=max(1, fft_bins - 1), valstep=1, orientation="vertical") contrast_slider = Slider(ax_sctr, "Int max", 0, 100, valinit=100, valstep=1, orientation="vertical") + cb = CheckButtons(ax_cb, ["калибровка"], [False]) def _on_ylim_change(_val): try: @@ -690,6 +719,7 @@ def main(): ymax_slider.on_changed(_on_ylim_change) # Контраст влияет на верхнюю границу цветовой шкалы (процент от авто-диапазона) contrast_slider.on_changed(lambda _v: fig.canvas.draw_idle()) + cb.on_clicked(lambda _v: _set_calib_enabled()) except Exception: pass @@ -698,6 +728,7 @@ def main(): interval_ms = int(1000.0 / max_fps) frames_since_ylim_update = 0 + def ensure_buffer(_w: int): nonlocal ring, width, head, x_shared, ring_fft, freq_shared, ring_time if ring is not None: @@ -800,7 +831,7 @@ def main(): y_max_fft = float(fr_max) def drain_queue(): - nonlocal current_sweep, current_info + nonlocal current_sweep_raw, current_sweep_norm, current_info, last_calib_sweep drained = 0 while True: try: @@ -808,10 +839,26 @@ def main(): except Empty: break drained += 1 - current_sweep = s + current_sweep_raw = s current_info = info + ch = 0 + try: + ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0 + except Exception: + ch = 0 + if ch == 0: + last_calib_sweep = s + current_sweep_norm = None + sweep_for_proc = s + else: + if calib_enabled and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(s, last_calib_sweep) + sweep_for_proc = current_sweep_norm + else: + current_sweep_norm = None + sweep_for_proc = s ensure_buffer(s.size) - push_sweep(s) + push_sweep(sweep_for_proc) return drained def make_display_ring(): @@ -856,18 +903,26 @@ def main(): changed = drain_queue() > 0 # Обновление линии последнего свипа - if current_sweep is not None: - if x_shared is not None and current_sweep.size <= x_shared.size: - xs = x_shared[: current_sweep.size] + if current_sweep_raw is not None: + if x_shared is not None and current_sweep_raw.size <= x_shared.size: + xs = x_shared[: current_sweep_raw.size] else: - xs = np.arange(current_sweep.size, dtype=np.int32) - line_obj.set_data(xs, current_sweep) + xs = np.arange(current_sweep_raw.size, dtype=np.int32) + line_obj.set_data(xs, current_sweep_raw) + if last_calib_sweep is not None: + line_calib_obj.set_data(xs[: last_calib_sweep.size], last_calib_sweep) + else: + line_calib_obj.set_data([], []) + if current_sweep_norm is not None: + line_norm_obj.set_data(xs[: current_sweep_norm.size], current_sweep_norm) + else: + line_norm_obj.set_data([], []) # Лимиты по X постоянные под текущую ширину - ax_line.set_xlim(0, max(1, current_sweep.size - 1)) + ax_line.set_xlim(0, max(1, current_sweep_raw.size - 1)) # Адаптивные Y-лимиты (если не задан --ylim) if fixed_ylim is None: - y0 = float(np.nanmin(current_sweep)) - y1 = float(np.nanmax(current_sweep)) + y0 = float(np.nanmin(current_sweep_raw)) + y1 = float(np.nanmax(current_sweep_raw)) if np.isfinite(y0) and np.isfinite(y1): if y0 == y1: pad = max(1.0, abs(y0) * 0.05) @@ -880,10 +935,11 @@ def main(): ax_line.set_ylim(y0, y1) # Обновление спектра текущего свипа - take_fft = min(int(current_sweep.size), FFT_LEN) + sweep_for_fft = current_sweep_norm if current_sweep_norm is not None else current_sweep_raw + take_fft = min(int(sweep_for_fft.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) + seg = np.nan_to_num(sweep_for_fft[: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) @@ -964,7 +1020,16 @@ def main(): channel_text.set_text(f"chs {chs}") # Возвращаем обновлённые артисты - return (line_obj, img_obj, fft_line_obj, img_fft_obj, status_text, channel_text) + return ( + line_obj, + line_calib_obj, + line_norm_obj, + img_obj, + fft_line_obj, + img_fft_obj, + status_text, + channel_text, + ) ani = FuncAnimation(fig, update, interval=interval_ms, blit=False) @@ -1010,6 +1075,8 @@ def run_pyqtgraph(args): 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)) + curve_calib = p_line.plot(pen=pg.mkPen((220, 60, 60), width=1)) + curve_norm = p_line.plot(pen=pg.mkPen((60, 180, 90), width=1)) p_line.setLabel("bottom", "X") p_line.setLabel("left", "Y") ch_text = pg.TextItem("", anchor=(1, 1)) @@ -1049,9 +1116,15 @@ def run_pyqtgraph(args): img_fft = pg.ImageItem() p_spec.addItem(img_fft) + # Чекбокс калибровки + calib_cb = QtWidgets.QCheckBox("калибровка") + cb_proxy = QtWidgets.QGraphicsProxyWidget() + cb_proxy.setWidget(calib_cb) + win.addItem(cb_proxy, row=2, col=1) + # Статусная строка (внизу окна) status = pg.LabelItem(justify="left") - win.addItem(status, row=2, col=0, colspan=2) + win.addItem(status, row=3, col=0, colspan=2) # Состояние ring: Optional[np.ndarray] = None @@ -1059,7 +1132,9 @@ def run_pyqtgraph(args): head = 0 width: Optional[int] = None x_shared: Optional[np.ndarray] = None - current_sweep: Optional[np.ndarray] = None + current_sweep_raw: Optional[np.ndarray] = None + current_sweep_norm: Optional[np.ndarray] = None + last_calib_sweep: Optional[np.ndarray] = None current_info: Optional[SweepInfo] = None # Авто-уровни цветовой шкалы водопада сырых данных пересчитываются по видимой области. # Для спектров @@ -1070,6 +1145,7 @@ def run_pyqtgraph(args): # Параметры контраста водопада спектров (процентильная обрезка) spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None)) spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) + calib_enabled = False # Диапазон по Y: авто по умолчанию (поддерживает отрицательные значения) fixed_ylim: Optional[Tuple[float, float]] = None if args.ylim: @@ -1081,6 +1157,32 @@ def run_pyqtgraph(args): if fixed_ylim is not None: p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0) + def _normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: + w = min(raw.size, calib.size) + if w <= 0: + return raw + out = np.full_like(raw, np.nan, dtype=np.float32) + with np.errstate(divide="ignore", invalid="ignore"): + out[:w] = raw[:w] / calib[:w] + out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan) + return out + + def _set_calib_enabled(): + nonlocal calib_enabled, current_sweep_norm + try: + calib_enabled = bool(calib_cb.isChecked()) + except Exception: + calib_enabled = False + if calib_enabled and current_sweep_raw is not None and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(current_sweep_raw, last_calib_sweep) + else: + current_sweep_norm = None + + try: + calib_cb.stateChanged.connect(lambda _v: _set_calib_enabled()) + except Exception: + pass + def ensure_buffer(_w: int): nonlocal ring, ring_time, head, width, x_shared, ring_fft, freq_shared if ring is not None: @@ -1169,7 +1271,7 @@ def run_pyqtgraph(args): y_max_fft = float(fr_max) def drain_queue(): - nonlocal current_sweep, current_info + nonlocal current_sweep_raw, current_sweep_norm, current_info, last_calib_sweep drained = 0 while True: try: @@ -1177,10 +1279,26 @@ def run_pyqtgraph(args): except Empty: break drained += 1 - current_sweep = s + current_sweep_raw = s current_info = info + ch = 0 + try: + ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0 + except Exception: + ch = 0 + if ch == 0: + last_calib_sweep = s + current_sweep_norm = None + sweep_for_proc = s + else: + if calib_enabled and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(s, last_calib_sweep) + sweep_for_proc = current_sweep_norm + else: + current_sweep_norm = None + sweep_for_proc = s ensure_buffer(s.size) - push_sweep(s) + push_sweep(sweep_for_proc) return drained # Попытка применить LUT из колормэпа (если доступен) @@ -1194,24 +1312,33 @@ def run_pyqtgraph(args): def update(): changed = drain_queue() > 0 - if current_sweep is not None and x_shared is not None: - if current_sweep.size <= x_shared.size: - xs = x_shared[: current_sweep.size] + if current_sweep_raw is not None and x_shared is not None: + if current_sweep_raw.size <= x_shared.size: + xs = x_shared[: current_sweep_raw.size] else: - xs = np.arange(current_sweep.size) - curve.setData(xs, current_sweep, autoDownsample=True) + xs = np.arange(current_sweep_raw.size) + curve.setData(xs, current_sweep_raw, autoDownsample=True) + if last_calib_sweep is not None: + curve_calib.setData(xs[: last_calib_sweep.size], last_calib_sweep, autoDownsample=True) + else: + curve_calib.setData([], []) + if current_sweep_norm is not None: + curve_norm.setData(xs[: current_sweep_norm.size], current_sweep_norm, autoDownsample=True) + else: + curve_norm.setData([], []) if fixed_ylim is None: - y0 = float(np.nanmin(current_sweep)) - y1 = float(np.nanmax(current_sweep)) + y0 = float(np.nanmin(current_sweep_raw)) + y1 = float(np.nanmax(current_sweep_raw)) if np.isfinite(y0) and np.isfinite(y1): 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) + sweep_for_fft = current_sweep_norm if current_sweep_norm is not None else current_sweep_raw + take_fft = min(int(sweep_for_fft.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) + seg = np.nan_to_num(sweep_for_fft[: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)