13 Commits

Author SHA1 Message Date
awe
26c3dd7ad5 something working new format 2026-03-05 17:56:27 +03:00
awe
1e05b1f3fd 3 freq diversion 2026-03-02 15:43:41 +03:00
awe
8cc21316e7 try normalization after grad 2026-03-02 13:25:12 +03:00
awe
c199ab7f28 last implement diff 2026-02-27 17:43:32 +03:00
awe
33e1976233 remove half junk from spectre 2026-02-26 16:57:07 +03:00
awe
00323af0f0 arccos to apply 2026-02-26 14:00:56 +03:00
awe
f1652d072e done 2026-02-25 20:20:40 +03:00
awe
267ddedb19 fix binary format 2026-02-25 18:33:50 +03:00
d56e439bf2 WIP on normaliser: 2e6ad24 ad to gitignore 2026-02-20 20:32:02 +03:00
33bde7be5a index on normaliser: 2e6ad24 ad to gitignore 2026-02-20 20:32:02 +03:00
awe
2e6ad24aaa ad to gitignore 2026-02-19 18:34:59 +03:00
02fa3645d7 Now software can be run by: run_dataplotter /dev/ttyACM0 2026-02-18 23:07:17 +03:00
ece30f1cd5 impoved tty parser binary mode: now it supports 32-bit values of intensity 2026-02-18 23:01:34 +03:00
21 changed files with 2953 additions and 461 deletions

BIN
().npy Normal file

Binary file not shown.

4
.gitignore vendored
View File

@ -6,3 +6,7 @@ __pycache__/
*.bak
*.swp
*.swo
acm_9
build
.venv
sample_data

Binary file not shown.

View File

@ -75,16 +75,20 @@ def main():
else:
delay_per_byte = 0.0
_CHUNK = 4096
loop = 0
try:
while True:
loop += 1
print(f"[loop {loop}] {args.file}")
with open(args.file, "rb") as f:
for line in f:
os.write(master_fd, line)
while True:
chunk = f.read(_CHUNK)
if not chunk:
break
os.write(master_fd, chunk)
if delay_per_byte > 0:
time.sleep(delay_per_byte * len(line))
time.sleep(delay_per_byte * len(chunk))
except KeyboardInterrupt:
print("\nОстановлено.")
finally:

View File

@ -1,9 +1,17 @@
WF_WIDTH = 1000 # максимальное число точек в ряду водопада
FFT_LEN = 2048 # длина БПФ для спектра/водопада спектров
FFT_LEN = 4096 # длина БПФ для спектра/водопада спектров
LOG_EXP = 2.0 # основание экспоненты для опции --logscale
# Порог для инверсии сырых данных: если среднее значение свипа ниже порога —
# считаем, что сигнал «меньше нуля» и домножаем свип на -1
DATA_INVERSION_THRESHOLD = 10.0
# Частотная сетка рабочего свипа (положительная часть), ГГц
FREQ_MIN_GHZ = 3.323
FREQ_MAX_GHZ = 14.323
# Скорость света для перевода времени пролёта в one-way depth
SPEED_OF_LIGHT_M_S = 299_792_458.0
# Параметры IFFT-спектра (временной профиль из спектра 3.2..14.3 ГГц)
# Двусторонний спектр формируется как: [нули -14.3..-3.2 | нули -3.2..+3.2 | данные +3.2..+14.3]
ZEROS_LOW = 758 # нули от -14.3 до -3.2 ГГц

View File

@ -7,12 +7,10 @@ from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, FREQ_SPAN_GHZ, IFFT_LEN
_IFFT_T_MAX_NS = float((IFFT_LEN - 1) / (FREQ_SPAN_GHZ * 1e9) * 1e9)
from rfg_adc_plotter.constants import FFT_LEN, FREQ_MAX_GHZ, FREQ_MIN_GHZ, IFFT_LEN
from rfg_adc_plotter.io.sweep_reader import SweepReader
from rfg_adc_plotter.processing.normalizer import build_calib_envelopes
from rfg_adc_plotter.state.app_state import BACKGROUND_PATH, CALIB_ENVELOPE_PATH, AppState, format_status
from rfg_adc_plotter.state.app_state import AppState, format_status
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepPacket
@ -82,7 +80,8 @@ def run_matplotlib(args):
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.widgets import CheckButtons, Slider
from matplotlib.widgets import Button as MplButton
from matplotlib.widgets import CheckButtons, RadioButtons, Slider, TextBox
except Exception as e:
sys.stderr.write(f"[error] Нужны matplotlib и её зависимости: {e}\n")
sys.exit(1)
@ -96,6 +95,8 @@ def run_matplotlib(args):
stop_event,
fancy=bool(args.fancy),
bin_mode=bool(getattr(args, "bin_mode", False)),
logscale=bool(getattr(args, "logscale", False)),
debug=bool(getattr(args, "debug", False)),
)
reader.start()
@ -106,23 +107,33 @@ def run_matplotlib(args):
spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0))
fixed_ylim = _parse_ylim(getattr(args, "ylim", None))
norm_type = str(getattr(args, "norm_type", "projector")).strip().lower()
logscale_enabled = bool(getattr(args, "logscale", False))
state = AppState(norm_type=norm_type)
state.configure_capture_import(fancy=bool(args.fancy), logscale=bool(getattr(args, "logscale", False)))
ring = RingBuffer(max_sweeps)
try:
ring.set_fft_complex_mode(str(getattr(args, "ifft_complex_mode", "arccos")))
except Exception:
pass
# --- Создание фигуры ---
fig, axs = plt.subplots(2, 2, figsize=(12, 8))
(ax_line, ax_img), (ax_fft, ax_spec) = axs
if hasattr(fig.canvas.manager, "set_window_title"):
fig.canvas.manager.set_window_title(args.title)
fig.subplots_adjust(wspace=0.25, hspace=0.35, left=0.07, right=0.90, top=0.92, bottom=0.08)
fig.subplots_adjust(wspace=0.25, hspace=0.35, left=0.07, right=0.90, top=0.92, bottom=0.22)
# Статусная строка
status_text = fig.text(0.01, 0.01, "", ha="left", va="bottom", fontsize=8, family="monospace")
pipeline_text = fig.text(0.01, 0.03, "", ha="left", va="bottom", fontsize=8, family="monospace")
ref_text = fig.text(0.01, 0.05, "", ha="left", va="bottom", fontsize=8, family="monospace")
# График последнего свипа
line_obj, = ax_line.plot([], [], lw=1, color="tab:blue")
line_norm_obj, = ax_line.plot([], [], lw=1, color="tab:green")
line_pre_exp_obj, = ax_line.plot([], [], lw=1, color="tab:red")
line_post_exp_obj, = ax_line.plot([], [], lw=1, color="tab:green")
line_env_lo, = ax_line.plot([], [], lw=1, color="tab:orange", linestyle="--", alpha=0.7)
line_env_hi, = ax_line.plot([], [], lw=1, color="tab:orange", linestyle="--", alpha=0.7)
ax_line.set_title("Сырые данные", pad=1)
@ -135,10 +146,11 @@ def run_matplotlib(args):
ax_line.set_ylim(fixed_ylim)
# График спектра
fft_line_obj, = ax_fft.plot([], [], lw=1)
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("Мощность, дБ")
ax_fft.set_xlabel("Глубина, м")
ax_fft.set_ylabel("Амплитуда")
ax_fft.legend(loc="upper right", fontsize=8)
# Водопад сырых данных
img_obj = ax_img.imshow(
@ -157,8 +169,8 @@ def run_matplotlib(args):
np.zeros((1, 1), dtype=np.float32),
aspect="auto", interpolation="nearest", origin="lower", cmap=args.cmap,
)
ax_spec.set_title("B-scan (дБ)", pad=12)
ax_spec.set_ylabel("Время, нс")
ax_spec.set_title("B-scan", pad=12)
ax_spec.set_ylabel("Глубина, м")
try:
ax_spec.tick_params(axis="x", labelbottom=False)
except Exception:
@ -167,21 +179,161 @@ def run_matplotlib(args):
# Слайдеры и чекбокс
contrast_slider = None
try:
fft_bins = ring.fft_bins
fft_bins = ring.fft_bins if ring.fft_bins > 0 else IFFT_LEN
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])
ax_cb_file = fig.add_axes([0.92, 0.36, 0.08, 0.08])
ax_line_mode = fig.add_axes([0.92, 0.10, 0.08, 0.08])
ax_ifft_mode = fig.add_axes([0.92, 0.01, 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")
calib_cb = CheckButtons(ax_cb, ["калибровка"], [False])
calib_file_cb = CheckButtons(ax_cb_file, ["из файла"], [False])
line_mode_rb = RadioButtons(ax_line_mode, ("raw", "processed"), active=0)
ifft_mode_rb = RadioButtons(
ax_ifft_mode,
("arccos", "diff"),
active=(1 if ring.fft_complex_mode == "diff" else 0),
)
try:
ax_line_mode.set_title("Линия", fontsize=8, pad=2)
except Exception:
pass
try:
ax_ifft_mode.set_title("IFFT", fontsize=8, pad=2)
except Exception:
pass
line_mode_state = {"value": "raw"}
ifft_mode_state = {"value": str(ring.fft_complex_mode)}
import os as _os
if not _os.path.isfile(CALIB_ENVELOPE_PATH):
ax_cb_file.set_visible(False)
try:
import tkinter as _tk
from tkinter import filedialog as _tk_filedialog
_tk_available = True
except Exception:
_tk = None
_tk_filedialog = None
_tk_available = False
# Нижняя панель путей и кнопок (работает без Qt; выбор файла через tkinter опционален).
ax_calib_path = fig.add_axes([0.07, 0.14, 0.40, 0.04])
ax_calib_load = fig.add_axes([0.48, 0.14, 0.07, 0.04])
ax_calib_pick = fig.add_axes([0.56, 0.14, 0.06, 0.04])
ax_calib_sample = fig.add_axes([0.63, 0.14, 0.09, 0.04])
ax_calib_save = fig.add_axes([0.73, 0.14, 0.10, 0.04])
ax_bg_path = fig.add_axes([0.07, 0.09, 0.40, 0.04])
ax_bg_load = fig.add_axes([0.48, 0.09, 0.07, 0.04])
ax_bg_pick = fig.add_axes([0.56, 0.09, 0.06, 0.04])
ax_bg_sample = fig.add_axes([0.63, 0.09, 0.09, 0.04])
ax_bg_save2 = fig.add_axes([0.73, 0.09, 0.10, 0.04])
calib_path_box = TextBox(ax_calib_path, "Калибр", initial=state.calib_envelope_path)
bg_path_box = TextBox(ax_bg_path, "Фон", initial=state.background_path)
calib_load_btn2 = MplButton(ax_calib_load, "Загруз.")
calib_pick_btn2 = MplButton(ax_calib_pick, "Файл")
calib_sample_btn2 = MplButton(ax_calib_sample, "sample")
calib_save_btn2 = MplButton(ax_calib_save, "Сохр env")
bg_load_btn2 = MplButton(ax_bg_load, "Загруз.")
bg_pick_btn2 = MplButton(ax_bg_pick, "Файл")
bg_sample_btn2 = MplButton(ax_bg_sample, "sample")
bg_save_btn2 = MplButton(ax_bg_save2, "Сохр фон")
if not _tk_available:
try:
calib_pick_btn2.label.set_text("Файл-")
bg_pick_btn2.label.set_text("Файл-")
except Exception:
pass
def _tb_text(tb):
try:
return str(tb.text).strip()
except Exception:
return ""
def _pick_file_dialog(initial_path: str) -> str:
if not _tk_available or _tk is None or _tk_filedialog is None:
return ""
root = None
try:
root = _tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
except Exception:
root = None
try:
return str(
_tk_filedialog.askopenfilename(
initialdir=_os.path.dirname(initial_path) or ".",
initialfile=_os.path.basename(initial_path) or "",
title="Выбрать файл эталона (.npy или capture)",
)
)
finally:
try:
if root is not None:
root.destroy()
except Exception:
pass
def _sync_path_boxes():
try:
if _tb_text(calib_path_box) != state.calib_envelope_path:
calib_path_box.set_val(state.calib_envelope_path)
except Exception:
pass
try:
if _tb_text(bg_path_box) != state.background_path:
bg_path_box.set_val(state.background_path)
except Exception:
pass
def _refresh_status_texts():
pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}")
ref_text.set_text(state.format_reference_status())
try:
fig.canvas.draw_idle()
except Exception:
pass
def _line_mode() -> str:
return str(line_mode_state.get("value", "raw"))
def _refresh_checkboxes():
try:
# file-mode чекбокс показываем всегда; он активен при наличии пути/данных.
ax_cb_file.set_visible(True)
except Exception:
pass
def _load_calib_from_ui():
p = _tb_text(calib_path_box)
if p:
state.set_calib_envelope_path(p)
ok = state.load_calib_reference()
if ok and bool(calib_file_cb.get_status()[0]):
state.set_calib_mode("file")
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
_sync_path_boxes()
_refresh_checkboxes()
_refresh_status_texts()
return ok
def _load_bg_from_ui():
p = _tb_text(bg_path_box)
if p:
state.set_background_path(p)
ok = state.load_background_reference()
_sync_path_boxes()
_refresh_status_texts()
return ok
def _on_ylim_change(_val):
try:
@ -195,7 +347,7 @@ def run_matplotlib(args):
def _on_calib_file_clicked(_v):
use_file = bool(calib_file_cb.get_status()[0])
if use_file:
ok = state.load_calib_envelope(CALIB_ENVELOPE_PATH)
ok = _load_calib_from_ui()
if ok:
state.set_calib_mode("file")
else:
@ -203,17 +355,15 @@ def run_matplotlib(args):
else:
state.set_calib_mode("live")
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
_refresh_status_texts()
def _on_calib_clicked(_v):
import os as _os2
if _os2.path.isfile(CALIB_ENVELOPE_PATH):
ax_cb_file.set_visible(True)
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
fig.canvas.draw_idle()
_refresh_checkboxes()
_refresh_status_texts()
ax_btn_bg = fig.add_axes([0.92, 0.27, 0.08, 0.05])
ax_cb_bg = fig.add_axes([0.92, 0.20, 0.08, 0.06])
from matplotlib.widgets import Button as MplButton
save_bg_btn = MplButton(ax_btn_bg, "Сохр. фон")
bg_cb = CheckButtons(ax_cb_bg, ["вычет фона"], [False])
@ -221,39 +371,134 @@ def run_matplotlib(args):
ok = state.save_background()
if ok:
state.load_background()
fig.canvas.draw_idle()
_sync_path_boxes()
_refresh_status_texts()
def _on_bg_clicked(_v):
state.set_background_enabled(bool(bg_cb.get_status()[0]))
_refresh_status_texts()
def _on_calib_load_btn(_event):
_load_calib_from_ui()
def _on_calib_pick_btn(_event):
path = _pick_file_dialog(_tb_text(calib_path_box) or state.calib_envelope_path)
if not path:
return
state.set_calib_envelope_path(path)
_sync_path_boxes()
_refresh_status_texts()
def _on_calib_sample_btn(_event):
state.set_calib_envelope_path(_os.path.join("sample_data", "no_antennas_35dB_attenuators"))
_sync_path_boxes()
if _load_calib_from_ui() and not bool(calib_file_cb.get_status()[0]):
calib_file_cb.set_active(0)
def _on_calib_save_btn(_event):
state.save_calib_envelope()
_sync_path_boxes()
_refresh_status_texts()
def _on_bg_load_btn(_event):
_load_bg_from_ui()
def _on_bg_pick_btn(_event):
path = _pick_file_dialog(_tb_text(bg_path_box) or state.background_path)
if not path:
return
state.set_background_path(path)
_sync_path_boxes()
_refresh_status_texts()
def _on_bg_sample_btn(_event):
state.set_background_path(_os.path.join("sample_data", "empty"))
_sync_path_boxes()
_load_bg_from_ui()
def _on_bg_save_btn2(_event):
ok = state.save_background()
if ok:
state.load_background()
_sync_path_boxes()
_refresh_status_texts()
def _on_line_mode_clicked(label):
line_mode_state["value"] = str(label)
try:
fig.canvas.draw_idle()
except Exception:
pass
def _on_ifft_mode_clicked(label):
ifft_mode_state["value"] = str(label)
try:
ring.set_fft_complex_mode(str(label))
except Exception:
pass
fft_line_obj.set_data([], [])
_refresh_status_texts()
try:
fig.canvas.draw_idle()
except Exception:
pass
save_bg_btn.on_clicked(_on_save_bg)
bg_cb.on_clicked(_on_bg_clicked)
calib_load_btn2.on_clicked(_on_calib_load_btn)
calib_pick_btn2.on_clicked(_on_calib_pick_btn)
calib_sample_btn2.on_clicked(_on_calib_sample_btn)
calib_save_btn2.on_clicked(_on_calib_save_btn)
bg_load_btn2.on_clicked(_on_bg_load_btn)
bg_pick_btn2.on_clicked(_on_bg_pick_btn)
bg_sample_btn2.on_clicked(_on_bg_sample_btn)
bg_save_btn2.on_clicked(_on_bg_save_btn2)
line_mode_rb.on_clicked(_on_line_mode_clicked)
ifft_mode_rb.on_clicked(_on_ifft_mode_clicked)
ymin_slider.on_changed(_on_ylim_change)
ymax_slider.on_changed(_on_ylim_change)
contrast_slider.on_changed(lambda _v: fig.canvas.draw_idle())
calib_cb.on_clicked(_on_calib_clicked)
calib_file_cb.on_clicked(_on_calib_file_clicked)
_sync_path_boxes()
_refresh_checkboxes()
_refresh_status_texts()
except Exception:
calib_cb = None
line_mode_state = {"value": "raw"}
ifft_mode_state = {"value": str(getattr(ring, "fft_complex_mode", "arccos"))}
FREQ_MIN = 3.323
FREQ_MAX = 14.323
FREQ_MIN = float(FREQ_MIN_GHZ)
FREQ_MAX = float(FREQ_MAX_GHZ)
def _fft_depth_max() -> float:
axis = ring.fft_depth_axis_m
if axis is None or axis.size == 0:
return 1.0
try:
vmax = float(axis[-1])
except Exception:
vmax = float(np.nanmax(axis))
if not np.isfinite(vmax) or vmax <= 0.0:
return 1.0
return vmax
# --- Инициализация imshow при первом свипе ---
def _init_imshow_extents():
w = ring.width
ms = ring.max_sweeps
fb = ring.fft_bins
fb = max(1, int(ring.fft_bins))
depth_max = _fft_depth_max()
img_obj.set_data(np.zeros((w, ms), dtype=np.float32))
img_obj.set_extent((0, ms - 1, FREQ_MIN, FREQ_MAX))
ax_img.set_xlim(0, ms - 1)
ax_img.set_ylim(FREQ_MIN, FREQ_MAX)
img_fft_obj.set_data(np.zeros((fb, ms), dtype=np.float32))
img_fft_obj.set_extent((0, ms - 1, 0.0, _IFFT_T_MAX_NS))
img_fft_obj.set_extent((0, ms - 1, 0.0, depth_max))
ax_spec.set_xlim(0, ms - 1)
ax_spec.set_ylim(0.0, _IFFT_T_MAX_NS)
ax_fft.set_xlim(0.0, _IFFT_T_MAX_NS)
ax_spec.set_ylim(0.0, depth_max)
ax_fft.set_xlim(0.0, depth_max)
_imshow_initialized = [False]
@ -271,47 +516,90 @@ def run_matplotlib(args):
xs = ring.x_shared[: raw.size]
else:
xs = np.arange(raw.size, dtype=np.int32)
def _norm_to_max(data):
m = float(np.nanmax(np.abs(data)))
return data / m if m > 0.0 else data
line_obj.set_data(xs, _norm_to_max(raw))
if state.calib_mode == "file" and state.calib_file_envelope is not None:
upper = state.calib_file_envelope
lower = -upper
m_env = float(np.nanmax(np.abs(upper)))
if m_env <= 0.0:
m_env = 1.0
line_env_lo.set_data(xs[: upper.size], lower / m_env)
line_env_hi.set_data(xs[: upper.size], upper / m_env)
elif state.last_calib_sweep is not None:
calib = state.last_calib_sweep
m_calib = float(np.nanmax(np.abs(calib)))
if m_calib <= 0.0:
m_calib = 1.0
lower, upper = build_calib_envelopes(calib)
line_env_lo.set_data(xs[: calib.size], lower / m_calib)
line_env_hi.set_data(xs[: calib.size], upper / m_calib)
line_mode = str(line_mode_state.get("value", "raw"))
main = state.current_sweep_processed if line_mode == "processed" else raw
if main is not None:
line_obj.set_data(xs[: main.size], main)
else:
line_obj.set_data([], [])
if line_mode == "raw":
if state.calib_mode == "file" and state.calib_file_envelope is not None:
upper = np.asarray(state.calib_file_envelope, dtype=np.float32)
n_env = min(xs.size, upper.size)
if n_env > 0:
x_env = xs[:n_env]
y_env = upper[:n_env]
line_env_lo.set_data(x_env, -y_env)
line_env_hi.set_data(x_env, y_env)
else:
line_env_lo.set_data([], [])
line_env_hi.set_data([], [])
elif state.last_calib_sweep is not None:
calib = np.asarray(state.last_calib_sweep, dtype=np.float32)
lower, upper = build_calib_envelopes(calib)
n_env = min(xs.size, lower.size, upper.size)
if n_env > 0:
line_env_lo.set_data(xs[:n_env], lower[:n_env])
line_env_hi.set_data(xs[:n_env], upper[:n_env])
else:
line_env_lo.set_data([], [])
line_env_hi.set_data([], [])
else:
line_env_lo.set_data([], [])
line_env_hi.set_data([], [])
else:
line_env_lo.set_data([], [])
line_env_hi.set_data([], [])
if state.current_sweep_norm is not None:
line_norm_obj.set_data(xs[: state.current_sweep_norm.size], _norm_to_max(state.current_sweep_norm))
else:
line_norm_obj.set_data([], [])
ax_line.set_xlim(FREQ_MIN, FREQ_MAX)
if fixed_ylim is None:
ax_line.set_ylim(-1.05, 1.05)
ax_line.set_ylabel("/ max")
if logscale_enabled:
if state.current_sweep_pre_exp is not None:
pre = state.current_sweep_pre_exp
line_pre_exp_obj.set_data(xs[: pre.size], pre)
else:
line_pre_exp_obj.set_data([], [])
# Спектр — используем уже вычисленный в ring IFFT (временной профиль)
if ring.last_fft_vals is not None and ring.fft_time_axis is not None:
fft_vals = ring.last_fft_vals
xs_fft = ring.fft_time_axis
n = min(fft_vals.size, xs_fft.size)
fft_line_obj.set_data(xs_fft[:n], fft_vals[:n])
if np.isfinite(np.nanmin(fft_vals)) and np.isfinite(np.nanmax(fft_vals)):
ax_fft.set_xlim(0, float(xs_fft[n - 1]))
ax_fft.set_ylim(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)))
post = state.current_sweep_post_exp if state.current_sweep_post_exp is not None else raw
line_post_exp_obj.set_data(xs[: post.size], post)
if line_mode == "processed":
if state.current_sweep_processed is not None:
proc = state.current_sweep_processed
line_obj.set_data(xs[: proc.size], proc)
else:
line_obj.set_data([], [])
else:
line_obj.set_data(xs[: raw.size], raw)
line_norm_obj.set_data([], [])
else:
line_pre_exp_obj.set_data([], [])
line_post_exp_obj.set_data([], [])
if line_mode == "raw" and state.current_sweep_norm is not None:
line_norm_obj.set_data(
xs[: state.current_sweep_norm.size], state.current_sweep_norm
)
else:
line_norm_obj.set_data([], [])
ax_line.set_xlim(FREQ_MIN, FREQ_MAX)
if fixed_ylim is not None:
ax_line.set_ylim(fixed_ylim)
else:
ax_line.relim()
ax_line.autoscale_view(scalex=False, scaley=True)
ax_line.set_ylabel("Y")
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:
@ -331,6 +619,9 @@ def run_matplotlib(args):
disp_fft = ring.get_display_ring_fft()
disp_fft = ring.subtract_recent_mean_fft(disp_fft, spec_mean_sec)
img_fft_obj.set_data(disp_fft)
depth_max = _fft_depth_max()
img_fft_obj.set_extent((0, ring.max_sweeps - 1, 0.0, depth_max))
ax_spec.set_ylim(0.0, depth_max)
levels = ring.compute_fft_levels(disp_fft, spec_clip)
if levels is not None:
try:
@ -344,8 +635,27 @@ def run_matplotlib(args):
if changed and state.current_info:
status_text.set_text(format_status(state.current_info))
channel_text.set_text(state.format_channel_label())
pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}")
ref_text.set_text(state.format_reference_status())
elif changed:
pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}")
ref_text.set_text(state.format_reference_status())
return (line_obj, line_norm_obj, line_env_lo, line_env_hi, img_obj, fft_line_obj, img_fft_obj, status_text, channel_text)
return (
line_obj,
line_norm_obj,
line_pre_exp_obj,
line_post_exp_obj,
line_env_lo,
line_env_hi,
img_obj,
fft_line_obj,
img_fft_obj,
status_text,
pipeline_text,
ref_text,
channel_text,
)
ani = FuncAnimation(fig, update, interval=interval_ms, blit=False)
plt.show()

View File

@ -1,5 +1,6 @@
"""PyQtGraph-бэкенд реалтайм-плоттера свипов."""
import os
import sys
import threading
from queue import Queue
@ -7,16 +8,13 @@ from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import FREQ_SPAN_GHZ, IFFT_LEN
from rfg_adc_plotter.constants import FREQ_MAX_GHZ, FREQ_MIN_GHZ
from rfg_adc_plotter.io.sweep_reader import SweepReader
from rfg_adc_plotter.processing.normalizer import build_calib_envelopes
from rfg_adc_plotter.state.app_state import BACKGROUND_PATH, CALIB_ENVELOPE_PATH, AppState, format_status
from rfg_adc_plotter.state.app_state import AppState, format_status
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepPacket
# Максимальное значение временной оси IFFT в нс
_IFFT_T_MAX_NS = float((IFFT_LEN - 1) / (FREQ_SPAN_GHZ * 1e9) * 1e9)
def _parse_ylim(ylim_str: Optional[str]) -> Optional[Tuple[float, float]]:
if not ylim_str:
@ -90,6 +88,18 @@ def _visible_levels(
return (vmin, vmax)
def _short_path(path: str, max_len: int = 48) -> str:
p = str(path or "").strip()
if not p:
return "(не задан)"
if len(p) <= max_len:
return p
base = os.path.basename(p)
if len(base) <= max_len:
return f".../{base}"
return "..." + p[-(max_len - 3) :]
def run_pyqtgraph(args):
"""Быстрый GUI на PyQtGraph. Требует pyqtgraph и PyQt5/PySide6."""
try:
@ -113,6 +123,8 @@ def run_pyqtgraph(args):
stop_event,
fancy=bool(args.fancy),
bin_mode=bool(getattr(args, "bin_mode", False)),
logscale=bool(getattr(args, "logscale", False)),
debug=bool(getattr(args, "debug", False)),
)
reader.start()
@ -123,21 +135,37 @@ def run_pyqtgraph(args):
spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0))
fixed_ylim = _parse_ylim(getattr(args, "ylim", None))
norm_type = str(getattr(args, "norm_type", "projector")).strip().lower()
logscale_enabled = bool(getattr(args, "logscale", False))
state = AppState(norm_type=norm_type)
state.configure_capture_import(fancy=bool(args.fancy), logscale=bool(getattr(args, "logscale", False)))
ring = RingBuffer(max_sweeps)
try:
ring.set_fft_complex_mode(str(getattr(args, "ifft_complex_mode", "arccos")))
except Exception:
pass
try:
_qt_text_selectable = QtCore.Qt.TextSelectableByMouse
except Exception:
try:
_qt_text_selectable = QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
except Exception:
_qt_text_selectable = None
# --- Создание окна ---
pg.setConfigOptions(useOpenGL=True, antialias=False)
app = pg.mkQApp(args.title)
win = pg.GraphicsLayoutWidget(show=True, title=args.title)
win.resize(1200, 600)
win.resize(1280, 760)
# График последнего свипа (слева-сверху)
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_norm = p_line.plot(pen=pg.mkPen((60, 180, 90), width=1))
curve_pre_exp = p_line.plot(pen=pg.mkPen((220, 60, 60), width=1))
curve_post_exp = p_line.plot(pen=pg.mkPen((60, 180, 90), width=1))
curve_env_lo = p_line.plot(pen=pg.mkPen((255, 165, 0), width=1, style=QtCore.Qt.DashLine))
curve_env_hi = p_line.plot(pen=pg.mkPen((255, 165, 0), width=1, style=QtCore.Qt.DashLine))
p_line.setLabel("bottom", "Частота, ГГц")
@ -174,12 +202,12 @@ 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 = p_fft.plot(pen=pg.mkPen((255, 120, 80), width=1))
p_fft.setLabel("bottom", "Время, нс")
p_fft.setLabel("left", "Мощность, дБ")
curve_fft = p_fft.plot(pen=pg.mkPen((80, 120, 255), width=1))
p_fft.setLabel("bottom", "Глубина, м")
p_fft.setLabel("left", "Амплитуда")
# Водопад спектров (справа-снизу)
p_spec = win.addPlot(row=1, col=1, title="B-scan (дБ)")
p_spec = win.addPlot(row=1, col=1, title="B-scan")
p_spec.invertY(True)
p_spec.showGrid(x=False, y=False)
p_spec.setLabel("bottom", "Время (новое справа)")
@ -187,36 +215,62 @@ def run_pyqtgraph(args):
p_spec.getAxis("bottom").setStyle(showValues=False)
except Exception:
pass
p_spec.setLabel("left", "Время, нс")
p_spec.setLabel("left", "Глубина, м")
img_fft = pg.ImageItem()
p_spec.addItem(img_fft)
# Чекбоксы калибровки — в одном контейнере
# Блок управления калибровкой
calib_widget = QtWidgets.QWidget()
calib_layout = QtWidgets.QHBoxLayout(calib_widget)
calib_layout = QtWidgets.QVBoxLayout(calib_widget)
calib_layout.setContentsMargins(2, 2, 2, 2)
calib_layout.setSpacing(8)
calib_layout.setSpacing(4)
calib_row_1 = QtWidgets.QHBoxLayout()
calib_row_1.setSpacing(8)
calib_row_2 = QtWidgets.QHBoxLayout()
calib_row_2.setSpacing(6)
calib_cb = QtWidgets.QCheckBox("калибровка")
calib_file_cb = QtWidgets.QCheckBox("из файла")
calib_file_cb.setEnabled(False) # активируется только если файл существует
calib_file_cb.setEnabled(False)
calib_path_label = QtWidgets.QLabel()
calib_path_label.setMinimumWidth(260)
if _qt_text_selectable is not None:
calib_path_label.setTextInteractionFlags(_qt_text_selectable)
calib_pick_btn = QtWidgets.QPushButton("Файл…")
calib_load_btn = QtWidgets.QPushButton("Загрузить")
calib_save_btn = QtWidgets.QPushButton("Сохранить env")
calib_sample_btn = QtWidgets.QPushButton("sample calib")
calib_layout.addWidget(calib_cb)
calib_layout.addWidget(calib_file_cb)
calib_row_1.addWidget(calib_cb)
calib_row_1.addWidget(calib_file_cb)
calib_row_1.addStretch(1)
calib_row_2.addWidget(QtWidgets.QLabel("Калибр:"))
calib_row_2.addWidget(calib_path_label, 1)
calib_row_2.addWidget(calib_pick_btn)
calib_row_2.addWidget(calib_load_btn)
calib_row_2.addWidget(calib_save_btn)
calib_row_2.addWidget(calib_sample_btn)
calib_layout.addLayout(calib_row_1)
calib_layout.addLayout(calib_row_2)
cb_container_proxy = QtWidgets.QGraphicsProxyWidget()
cb_container_proxy.setWidget(calib_widget)
win.addItem(cb_container_proxy, row=2, col=1)
def _check_file_cb_available():
import os
calib_file_cb.setEnabled(os.path.isfile(CALIB_ENVELOPE_PATH))
_check_file_cb_available()
def _refresh_calib_controls():
calib_path_label.setText(_short_path(state.calib_envelope_path))
calib_path_label.setToolTip(state.calib_envelope_path)
calib_load_btn.setEnabled(bool(state.calib_envelope_path) and os.path.isfile(state.calib_envelope_path))
calib_save_btn.setEnabled(state.last_calib_sweep is not None)
# Переключатель file-mode доступен, если файл существует или уже загружен в память.
calib_file_cb.setEnabled(state.has_calib_envelope_file() or state.calib_file_envelope is not None)
def _on_calib_file_toggled(checked):
if checked:
ok = state.load_calib_envelope(CALIB_ENVELOPE_PATH)
ok = state.load_calib_reference()
if ok:
state.set_calib_mode("file")
else:
@ -224,60 +278,265 @@ def run_pyqtgraph(args):
else:
state.set_calib_mode("live")
state.set_calib_enabled(calib_cb.isChecked())
_refresh_calib_controls()
_refresh_pipeline_label()
def _on_calib_toggled(_v):
_check_file_cb_available()
state.set_calib_enabled(calib_cb.isChecked())
_refresh_calib_controls()
_refresh_pipeline_label()
def _on_pick_calib_path():
path, _ = QtWidgets.QFileDialog.getOpenFileName(
win,
"Выбрать источник калибровки (.npy или capture)",
state.calib_envelope_path,
"Все файлы (*);;NumPy (*.npy)",
)
if not path:
return
state.set_calib_envelope_path(path)
if calib_file_cb.isChecked():
if state.load_calib_reference():
state.set_calib_mode("file")
state.set_calib_enabled(calib_cb.isChecked())
else:
calib_file_cb.setChecked(False)
_refresh_calib_controls()
_refresh_pipeline_label()
def _on_load_calib():
if state.load_calib_reference():
if calib_file_cb.isChecked():
state.set_calib_mode("file")
state.set_calib_enabled(calib_cb.isChecked())
_refresh_calib_controls()
_refresh_pipeline_label()
def _on_save_calib():
if state.save_calib_envelope():
if calib_file_cb.isChecked():
state.load_calib_envelope()
state.set_calib_mode("file")
state.set_calib_enabled(calib_cb.isChecked())
_refresh_calib_controls()
_refresh_pipeline_label()
def _on_sample_calib():
sample_path = os.path.join("sample_data", "no_antennas_35dB_attenuators")
state.set_calib_envelope_path(sample_path)
if state.load_calib_reference():
calib_file_cb.setChecked(True)
state.set_calib_mode("file")
state.set_calib_enabled(calib_cb.isChecked())
_refresh_calib_controls()
_refresh_pipeline_label()
calib_cb.stateChanged.connect(_on_calib_toggled)
calib_file_cb.stateChanged.connect(lambda _v: _on_calib_file_toggled(calib_file_cb.isChecked()))
calib_pick_btn.clicked.connect(_on_pick_calib_path)
calib_load_btn.clicked.connect(_on_load_calib)
calib_save_btn.clicked.connect(_on_save_calib)
calib_sample_btn.clicked.connect(_on_sample_calib)
# Кнопка сохранения фона + чекбокс вычета фона
# Блок управления фоном
bg_widget = QtWidgets.QWidget()
bg_layout = QtWidgets.QHBoxLayout(bg_widget)
bg_layout = QtWidgets.QVBoxLayout(bg_widget)
bg_layout.setContentsMargins(2, 2, 2, 2)
bg_layout.setSpacing(8)
bg_layout.setSpacing(4)
save_bg_btn = QtWidgets.QPushButton("Сохр. фон")
bg_row_1 = QtWidgets.QHBoxLayout()
bg_row_1.setSpacing(8)
bg_row_2 = QtWidgets.QHBoxLayout()
bg_row_2.setSpacing(6)
save_bg_btn = QtWidgets.QPushButton("Сохранить фон")
load_bg_btn = QtWidgets.QPushButton("Загрузить")
bg_pick_btn = QtWidgets.QPushButton("Файл…")
bg_sample_btn = QtWidgets.QPushButton("sample bg")
bg_cb = QtWidgets.QCheckBox("вычет фона")
bg_cb.setEnabled(False)
bg_cb.setEnabled(False) # активируется при успешной загрузке/сохранении
bg_path_label = QtWidgets.QLabel()
bg_path_label.setMinimumWidth(260)
if _qt_text_selectable is not None:
bg_path_label.setTextInteractionFlags(_qt_text_selectable)
bg_layout.addWidget(save_bg_btn)
bg_layout.addWidget(bg_cb)
bg_row_1.addWidget(bg_cb)
bg_row_1.addStretch(1)
bg_row_2.addWidget(QtWidgets.QLabel("Фон:"))
bg_row_2.addWidget(bg_path_label, 1)
bg_row_2.addWidget(bg_pick_btn)
bg_row_2.addWidget(load_bg_btn)
bg_row_2.addWidget(save_bg_btn)
bg_row_2.addWidget(bg_sample_btn)
bg_layout.addLayout(bg_row_1)
bg_layout.addLayout(bg_row_2)
bg_container_proxy = QtWidgets.QGraphicsProxyWidget()
bg_container_proxy.setWidget(bg_widget)
win.addItem(bg_container_proxy, row=2, col=0)
def _refresh_bg_controls():
bg_path_label.setText(_short_path(state.background_path))
bg_path_label.setToolTip(state.background_path)
load_bg_btn.setEnabled(bool(state.background_path) and os.path.isfile(state.background_path))
bg_cb.setEnabled(state.background is not None or state.background_source_type == "capture_raw")
def _on_pick_bg_path():
path, _ = QtWidgets.QFileDialog.getOpenFileName(
win,
"Выбрать источник фона (.npy или capture)",
state.background_path,
"Все файлы (*);;NumPy (*.npy)",
)
if not path:
return
state.set_background_path(path)
if bg_cb.isChecked():
if not state.load_background_reference():
bg_cb.setChecked(False)
_refresh_bg_controls()
_refresh_pipeline_label()
def _on_load_bg():
state.load_background_reference()
_refresh_bg_controls()
_refresh_pipeline_label()
def _on_save_bg():
ok = state.save_background()
if ok:
state.load_background()
bg_cb.setEnabled(True)
_refresh_bg_controls()
_refresh_pipeline_label()
def _on_bg_toggled(_v):
state.set_background_enabled(bg_cb.isChecked())
_refresh_pipeline_label()
def _on_sample_bg():
sample_path = os.path.join("sample_data", "empty")
state.set_background_path(sample_path)
if state.load_background_reference():
bg_cb.setEnabled(True)
_refresh_bg_controls()
_refresh_pipeline_label()
bg_pick_btn.clicked.connect(_on_pick_bg_path)
load_bg_btn.clicked.connect(_on_load_bg)
save_bg_btn.clicked.connect(_on_save_bg)
bg_cb.stateChanged.connect(lambda _v: state.set_background_enabled(bg_cb.isChecked()))
bg_cb.stateChanged.connect(_on_bg_toggled)
bg_sample_btn.clicked.connect(_on_sample_bg)
# Переключатель отображения верхнего линейного графика
line_mode_widget = QtWidgets.QWidget()
line_mode_layout = QtWidgets.QHBoxLayout(line_mode_widget)
line_mode_layout.setContentsMargins(2, 2, 2, 2)
line_mode_layout.setSpacing(8)
line_mode_layout.addWidget(QtWidgets.QLabel("Линия:"))
line_mode_raw_rb = QtWidgets.QRadioButton("raw")
line_mode_proc_rb = QtWidgets.QRadioButton("processed")
line_mode_raw_rb.setChecked(True)
line_mode_layout.addWidget(line_mode_raw_rb)
line_mode_layout.addWidget(line_mode_proc_rb)
line_mode_layout.addStretch(1)
line_mode_proxy = QtWidgets.QGraphicsProxyWidget()
line_mode_proxy.setWidget(line_mode_widget)
win.addItem(line_mode_proxy, row=6, col=0, colspan=2)
def _line_mode() -> str:
return "processed" if line_mode_proc_rb.isChecked() else "raw"
# Переключатель режима реконструкции комплексного спектра перед IFFT
ifft_mode_widget = QtWidgets.QWidget()
ifft_mode_layout = QtWidgets.QHBoxLayout(ifft_mode_widget)
ifft_mode_layout.setContentsMargins(2, 2, 2, 2)
ifft_mode_layout.setSpacing(8)
ifft_mode_layout.addWidget(QtWidgets.QLabel("IFFT mode:"))
ifft_mode_arccos_rb = QtWidgets.QRadioButton("arccos")
ifft_mode_diff_rb = QtWidgets.QRadioButton("diff")
if ring.fft_complex_mode == "diff":
ifft_mode_diff_rb.setChecked(True)
else:
ifft_mode_arccos_rb.setChecked(True)
ifft_mode_layout.addWidget(ifft_mode_arccos_rb)
ifft_mode_layout.addWidget(ifft_mode_diff_rb)
ifft_mode_layout.addStretch(1)
ifft_mode_proxy = QtWidgets.QGraphicsProxyWidget()
ifft_mode_proxy.setWidget(ifft_mode_widget)
win.addItem(ifft_mode_proxy, row=7, col=0, colspan=2)
# Статусная строка
status = pg.LabelItem(justify="left")
win.addItem(status, row=3, col=0, colspan=2)
pipeline_status = pg.LabelItem(justify="left")
win.addItem(pipeline_status, row=4, col=0, colspan=2)
ref_status = pg.LabelItem(justify="left")
win.addItem(ref_status, row=5, col=0, colspan=2)
def _refresh_pipeline_label():
txt = state.format_pipeline_status()
txt = f"{txt} | cplx:{ring.fft_complex_mode}"
trace = state.format_stage_trace()
if trace:
txt = f"{txt} | trace: {trace}"
pipeline_status.setText(txt)
ref_status.setText(state.format_reference_status())
def _apply_ifft_complex_mode(mode: str):
try:
changed = ring.set_fft_complex_mode(mode)
except Exception:
changed = False
if changed:
try:
curve_fft.setData([], [])
except Exception:
pass
_refresh_pipeline_label()
ifft_mode_arccos_rb.toggled.connect(
lambda checked: _apply_ifft_complex_mode("arccos") if checked else None
)
ifft_mode_diff_rb.toggled.connect(
lambda checked: _apply_ifft_complex_mode("diff") if checked else None
)
_refresh_calib_controls()
_refresh_bg_controls()
_refresh_pipeline_label()
_imshow_initialized = [False]
FREQ_MIN = 3.323
FREQ_MAX = 14.323
FREQ_MIN = float(FREQ_MIN_GHZ)
FREQ_MAX = float(FREQ_MAX_GHZ)
def _fft_depth_max() -> float:
axis = ring.fft_depth_axis_m
if axis is None or axis.size == 0:
return 1.0
try:
vmax = float(axis[-1])
except Exception:
vmax = float(np.nanmax(axis))
if not np.isfinite(vmax) or vmax <= 0.0:
return 1.0
return vmax
def _init_imshow_extents():
ms = ring.max_sweeps
fb = ring.fft_bins
img.setImage(ring.ring.T, autoLevels=False)
img.setRect(pg.QtCore.QRectF(0.0, FREQ_MIN, float(ms), FREQ_MAX - FREQ_MIN))
p_img.setRange(xRange=(0, ms - 1), yRange=(FREQ_MIN, FREQ_MAX), padding=0)
p_line.setXRange(FREQ_MIN, FREQ_MAX, padding=0)
img_fft.setImage(ring.ring_fft.T, autoLevels=False)
img_fft.setRect(pg.QtCore.QRectF(0.0, 0.0, float(ms), _IFFT_T_MAX_NS))
p_spec.setRange(xRange=(0, ms - 1), yRange=(0.0, _IFFT_T_MAX_NS), padding=0)
p_fft.setXRange(0.0, _IFFT_T_MAX_NS, padding=0)
disp_fft = ring.get_display_ring_fft()
img_fft.setImage(disp_fft, autoLevels=False)
depth_max = _fft_depth_max()
img_fft.setRect(pg.QtCore.QRectF(0.0, 0.0, float(ms), depth_max))
p_spec.setRange(xRange=(0, ms - 1), yRange=(0.0, depth_max), padding=0)
p_fft.setXRange(0.0, depth_max, padding=0)
def _img_rect(ms: int) -> "pg.QtCore.QRectF":
return pg.QtCore.QRectF(0.0, FREQ_MIN, float(ms), FREQ_MAX - FREQ_MIN)
@ -288,49 +547,99 @@ def run_pyqtgraph(args):
if changed and not _imshow_initialized[0] and ring.is_ready:
_init_imshow_extents()
_imshow_initialized[0] = True
if changed:
_refresh_calib_controls()
_refresh_bg_controls()
_refresh_pipeline_label()
# Линейный график свипа
if state.current_sweep_raw is not None and ring.x_shared is not None:
raw = state.current_sweep_raw
xs = ring.x_shared[: raw.size] if raw.size <= ring.x_shared.size else np.arange(raw.size)
def _norm_to_max(data):
m = float(np.nanmax(np.abs(data)))
return data / m if m > 0.0 else data
curve.setData(xs, _norm_to_max(raw), autoDownsample=True)
if state.calib_mode == "file" and state.calib_file_envelope is not None:
upper = state.calib_file_envelope
lower = -upper
m_env = float(np.nanmax(np.abs(upper)))
if m_env <= 0.0:
m_env = 1.0
curve_env_lo.setData(xs[: upper.size], lower / m_env, autoDownsample=True)
curve_env_hi.setData(xs[: upper.size], upper / m_env, autoDownsample=True)
elif state.last_calib_sweep is not None:
calib = state.last_calib_sweep
m_calib = float(np.nanmax(np.abs(calib)))
if m_calib <= 0.0:
m_calib = 1.0
lower, upper = build_calib_envelopes(calib)
curve_env_lo.setData(xs[: calib.size], lower / m_calib, autoDownsample=True)
curve_env_hi.setData(xs[: calib.size], upper / m_calib, autoDownsample=True)
line_mode = _line_mode()
main = state.current_sweep_processed if line_mode == "processed" else raw
if main is not None:
curve.setData(xs[: main.size], main, autoDownsample=True)
else:
curve.setData([], [])
if line_mode == "raw":
if state.calib_mode == "file" and state.calib_file_envelope is not None:
upper = np.asarray(state.calib_file_envelope, dtype=np.float32)
n_env = min(xs.size, upper.size)
if n_env > 0:
x_env = xs[:n_env]
y_env = upper[:n_env]
curve_env_lo.setData(x_env, -y_env, autoDownsample=True)
curve_env_hi.setData(x_env, y_env, autoDownsample=True)
else:
curve_env_lo.setData([], [])
curve_env_hi.setData([], [])
elif state.last_calib_sweep is not None:
calib = np.asarray(state.last_calib_sweep, dtype=np.float32)
lower, upper = build_calib_envelopes(calib)
n_env = min(xs.size, upper.size, lower.size)
if n_env > 0:
curve_env_lo.setData(xs[:n_env], lower[:n_env], autoDownsample=True)
curve_env_hi.setData(xs[:n_env], upper[:n_env], autoDownsample=True)
else:
curve_env_lo.setData([], [])
curve_env_hi.setData([], [])
else:
curve_env_lo.setData([], [])
curve_env_hi.setData([], [])
else:
curve_env_lo.setData([], [])
curve_env_hi.setData([], [])
if state.current_sweep_norm is not None:
curve_norm.setData(xs[: state.current_sweep_norm.size], _norm_to_max(state.current_sweep_norm), autoDownsample=True)
else:
curve_norm.setData([], [])
if fixed_ylim is None:
p_line.setYRange(-1.05, 1.05, padding=0)
p_line.setLabel("left", "/ max")
if logscale_enabled:
if state.current_sweep_pre_exp is not None:
pre = state.current_sweep_pre_exp
curve_pre_exp.setData(xs[: pre.size], pre, autoDownsample=True)
else:
curve_pre_exp.setData([], [])
# Спектр — используем уже вычисленный в ring IFFT (временной профиль)
if ring.last_fft_vals is not None and ring.fft_time_axis is not None:
fft_vals = ring.last_fft_vals
xs_fft = ring.fft_time_axis
n = min(fft_vals.size, xs_fft.size)
curve_fft.setData(xs_fft[:n], fft_vals[:n])
p_fft.setYRange(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)), padding=0)
post = state.current_sweep_post_exp if state.current_sweep_post_exp is not None else raw
curve_post_exp.setData(xs[: post.size], post, autoDownsample=True)
if line_mode == "processed":
if state.current_sweep_processed is not None:
proc = state.current_sweep_processed
curve.setData(xs[: proc.size], proc, autoDownsample=True)
else:
curve.setData([], [])
else:
curve.setData(xs[: raw.size], raw, autoDownsample=True)
curve_norm.setData([], [])
else:
curve_pre_exp.setData([], [])
curve_post_exp.setData([], [])
if line_mode == "raw" and state.current_sweep_norm is not None:
curve_norm.setData(
xs[: state.current_sweep_norm.size],
state.current_sweep_norm,
autoDownsample=True,
)
else:
curve_norm.setData([], [])
if fixed_ylim is not None:
p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0)
else:
p_line.enableAutoRange(axis="y", enable=True)
p_line.setLabel("left", "Y")
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([], [])
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:
@ -358,6 +667,8 @@ def run_pyqtgraph(args):
except Exception:
pass
ch_text.setText(state.format_channel_label())
elif changed:
_refresh_pipeline_label()
# Водопад спектров — новые данные справа (без реверса)
if changed and ring.is_ready:
@ -368,7 +679,7 @@ def run_pyqtgraph(args):
img_fft.setImage(disp_fft, autoLevels=False, levels=levels)
else:
img_fft.setImage(disp_fft, autoLevels=False)
img_fft.setRect(pg.QtCore.QRectF(0.0, 0.0, float(ring.max_sweeps), _IFFT_T_MAX_NS))
img_fft.setRect(pg.QtCore.QRectF(0.0, 0.0, float(ring.max_sweeps), _fft_depth_max()))
timer = pg.QtCore.QTimer()
timer.timeout.connect(update)

View File

@ -0,0 +1,219 @@
"""Загрузка эталонов (калибровка/фон) из .npy или бинарных capture-файлов."""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
import os
from typing import Iterable, List, Optional, Tuple
import numpy as np
from rfg_adc_plotter.io.sweep_parser_core import BinaryRecordStreamParser, SweepAssembler
from rfg_adc_plotter.types import SweepPacket
@dataclass(frozen=True)
class CaptureParseSummary:
path: str
format: str # "npy" | "bin_capture"
sweeps_total: int
sweeps_valid: int
channels_seen: Tuple[int, ...]
dominant_width: Optional[int]
dominant_n_valid: Optional[int]
aggregation: str
warnings: Tuple[str, ...]
@dataclass(frozen=True)
class ReferenceLoadResult:
vector: np.ndarray
summary: CaptureParseSummary
kind: str # "calibration_envelope" | "background_raw" | "background_processed"
source_type: str # "npy" | "capture"
def detect_reference_file_format(path: str) -> Optional[str]:
"""Определить формат файла эталона: .npy или бинарный capture."""
p = str(path).strip()
if not p or not os.path.isfile(p):
return None
if p.lower().endswith(".npy"):
return "npy"
try:
size = os.path.getsize(p)
except Exception:
return None
if size <= 0:
return None
try:
with open(p, "rb") as f:
sample = f.read(min(size, 256 * 1024))
except Exception:
return None
if len(sample) < 8:
return None
# Универсальный sniff: прогоняем тем же потоковым парсером,
# который используется в realtime/capture-import.
parser = BinaryRecordStreamParser()
_ = parser.feed(sample)
if parser.start_count >= 1 and parser.point_count >= 16:
return "bin_capture"
return None
def load_capture_sweeps(path: str, *, fancy: bool = False, logscale: bool = False) -> List[SweepPacket]:
"""Загрузить свипы из бинарного capture-файла в формате --bin."""
parser = BinaryRecordStreamParser()
assembler = SweepAssembler(fancy=fancy, logscale=logscale, debug=False)
sweeps: List[SweepPacket] = []
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
events = parser.feed(chunk)
for ev in events:
packets = assembler.consume_binary_event(ev)
if packets:
sweeps.extend(packets)
tail = assembler.finalize_current()
if tail is not None:
sweeps.append(tail)
return sweeps
def _mode_int(values: Iterable[int]) -> Optional[int]:
vals = [int(v) for v in values]
if not vals:
return None
ctr = Counter(vals)
return int(max(ctr.items(), key=lambda kv: (kv[1], kv[0]))[0])
def aggregate_capture_reference(
sweeps: List[SweepPacket],
*,
channel: int = 0,
method: str = "median",
path: str = "",
) -> Tuple[np.ndarray, CaptureParseSummary]:
"""Отфильтровать и агрегировать свипы из capture в один эталонный вектор."""
ch_target = int(channel)
meth = str(method).strip().lower() or "median"
warnings: list[str] = []
if meth != "median":
warnings.append(f"aggregation '{meth}' не поддерживается, использую median")
meth = "median"
channels_seen: set[int] = set()
candidate_rows: list[np.ndarray] = []
widths: list[int] = []
n_valids: list[int] = []
for sweep, info in sweeps:
chs = info.get("chs") if isinstance(info, dict) else None
ch_set: set[int] = set()
if isinstance(chs, (list, tuple, set)):
for v in chs:
try:
ch_set.add(int(v))
except Exception:
pass
else:
try:
ch_set.add(int(info.get("ch", 0))) # type: ignore[union-attr]
except Exception:
pass
channels_seen.update(ch_set)
if ch_target not in ch_set:
continue
row = np.asarray(sweep, dtype=np.float32).reshape(-1)
candidate_rows.append(row)
widths.append(int(row.size))
n_valids.append(int(np.count_nonzero(np.isfinite(row))))
sweeps_total = len(sweeps)
if not candidate_rows:
summary = CaptureParseSummary(
path=path,
format="bin_capture",
sweeps_total=sweeps_total,
sweeps_valid=0,
channels_seen=tuple(sorted(channels_seen)),
dominant_width=None,
dominant_n_valid=None,
aggregation=meth,
warnings=tuple(warnings + [f"канал ch{ch_target} не найден"]),
)
raise ValueError(summary.warnings[-1])
dominant_width = _mode_int(widths)
dominant_n_valid = _mode_int(n_valids)
if dominant_width is None or dominant_n_valid is None:
summary = CaptureParseSummary(
path=path,
format="bin_capture",
sweeps_total=sweeps_total,
sweeps_valid=0,
channels_seen=tuple(sorted(channels_seen)),
dominant_width=dominant_width,
dominant_n_valid=dominant_n_valid,
aggregation=meth,
warnings=tuple(warnings + ["не удалось определить доминирующие параметры свипа"]),
)
raise ValueError(summary.warnings[-1])
valid_rows: list[np.ndarray] = []
n_valid_threshold = max(1, int(np.floor(0.95 * dominant_n_valid)))
for row in candidate_rows:
if row.size != dominant_width:
continue
n_valid = int(np.count_nonzero(np.isfinite(row)))
if n_valid < n_valid_threshold:
continue
valid_rows.append(row)
if not valid_rows:
warnings.append("после фильтрации не осталось валидных свипов")
summary = CaptureParseSummary(
path=path,
format="bin_capture",
sweeps_total=sweeps_total,
sweeps_valid=0,
channels_seen=tuple(sorted(channels_seen)),
dominant_width=dominant_width,
dominant_n_valid=dominant_n_valid,
aggregation=meth,
warnings=tuple(warnings),
)
raise ValueError(summary.warnings[-1])
# Детерминированная агрегация: медиана по валидным свипам.
stack = np.stack(valid_rows, axis=0).astype(np.float32, copy=False)
vector = np.nanmedian(stack, axis=0).astype(np.float32, copy=False)
if len(valid_rows) < len(candidate_rows):
warnings.append(f"отфильтровано {len(candidate_rows) - len(valid_rows)} неполных/нестандартных свипов")
summary = CaptureParseSummary(
path=path,
format="bin_capture",
sweeps_total=sweeps_total,
sweeps_valid=len(valid_rows),
channels_seen=tuple(sorted(channels_seen)),
dominant_width=dominant_width,
dominant_n_valid=dominant_n_valid,
aggregation=meth,
warnings=tuple(warnings),
)
return vector, summary

View File

@ -0,0 +1,368 @@
"""Переиспользуемые компоненты парсинга бинарных свипов и сборки SweepPacket."""
from __future__ import annotations
import math
from collections import deque
import time
from typing import List, Optional, Sequence, Set, Tuple
import numpy as np
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD, LOG_EXP
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, 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:
"""Преобразование 32-bit слова в знаковое значение."""
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:
"""Инкрементальный парсер бинарных записей нескольких 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()
self.bytes_consumed: int = 0
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:
self._buf += data
events: List[BinaryEvent] = []
buf = self._buf
while len(buf) >= 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), float(u32_to_i32(value_u32))))
del buf[:8]
self.bytes_consumed += 8
self.point_count += 1
continue
del buf[:1]
self.bytes_consumed += 1
self.desync_count += 1
return events
def buffered_size(self) -> int:
return len(self._buf)
def clear_buffer_keep_tail(self, max_tail: int = 262_144):
if len(self._buf) > max_tail:
del self._buf[:-max_tail]
class SweepAssembler:
"""Собирает точки в свип и применяет ту же постобработку, что realtime parser."""
def __init__(self, fancy: bool = False, logscale: bool = False, debug: bool = False):
self._fancy = bool(fancy)
self._logscale = bool(logscale)
self._debug = bool(debug)
self._max_width: int = 0
self._sweep_idx: int = 0
self._last_sweep_ts: Optional[float] = None
self._n_valid_hist = deque()
self._xs: list[int] = []
self._ys: list[float] = []
self._cur_channel: Optional[int] = None
self._cur_channels: set[int] = set()
def reset_current(self):
self._xs.clear()
self._ys.clear()
self._cur_channel = None
self._cur_channels.clear()
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(float(y))
def start_new_sweep(self, ch: int, now_ts: Optional[float] = None) -> Optional[SweepPacket]:
packet = self.finalize_current(now_ts=now_ts)
self.reset_current()
self._cur_channel = int(ch)
self._cur_channels.add(int(ch))
return packet
def consume_binary_event(self, event: BinaryEvent, now_ts: Optional[float] = None) -> List[SweepPacket]:
out: List[SweepPacket] = []
tag = event[0]
if tag == "start":
packet = self.start_new_sweep(int(event[1]), now_ts=now_ts)
if packet is not None:
out.append(packet)
return out
# point
_tag, ch, x, y = event # type: ignore[misc]
self.add_point(int(ch), int(x), float(y))
return out
def finalize_arrays(
self,
xs: Sequence[int],
ys: Sequence[float],
channels: Optional[Set[int]],
now_ts: Optional[float] = None,
) -> Optional[SweepPacket]:
if self._debug:
if not xs:
import sys
sys.stderr.write("[debug] _finalize_current: xs пуст — свип пропущен\n")
else:
import sys
sys.stderr.write(
f"[debug] _finalize_current: {len(xs)} точек → свип #{self._sweep_idx + 1}\n"
)
if not xs:
return None
ch_list = sorted(channels) if channels else [0]
ch_primary = ch_list[0] if ch_list else 0
max_x = max(int(v) for v in xs)
width = max_x + 1
self._max_width = max(self._max_width, width)
target_width = self._max_width if self._fancy else width
sweep = np.full((target_width,), np.nan, dtype=np.float32)
try:
idx = np.asarray(xs, dtype=np.int64)
vals = np.asarray(ys, dtype=np.float32)
sweep[idx] = vals
except Exception:
for x, y in zip(xs, ys):
xi = int(x)
if 0 <= xi < target_width:
sweep[xi] = float(y)
n_valid_cur = int(np.count_nonzero(np.isfinite(sweep)))
if self._fancy:
try:
known = ~np.isnan(sweep)
if np.any(known):
known_idx = np.nonzero(known)[0]
for i0, i1 in zip(known_idx[:-1], known_idx[1:]):
if i1 - i0 > 1:
avg = (sweep[i0] + sweep[i1]) * 0.5
sweep[i0 + 1 : i1] = avg
first_idx = int(known_idx[0])
last_idx = int(known_idx[-1])
if first_idx > 0:
sweep[:first_idx] = sweep[first_idx]
if last_idx < sweep.size - 1:
sweep[last_idx + 1 :] = sweep[last_idx]
except Exception:
pass
try:
m = float(np.nanmean(sweep))
if np.isfinite(m) and m < DATA_INVERSION_THRESHOLD:
sweep *= -1.0
except Exception:
pass
pre_exp_sweep = None
if self._logscale:
try:
pre_exp_sweep = sweep.copy()
with np.errstate(over="ignore", invalid="ignore"):
sweep = np.power(LOG_EXP, np.asarray(sweep, dtype=np.float64)).astype(np.float32)
sweep[~np.isfinite(sweep)] = np.nan
except Exception:
pass
self._sweep_idx += 1
if len(ch_list) > 1:
import sys
sys.stderr.write(f"[warn] Sweep {self._sweep_idx}: изменялся номер канала: {ch_list}\n")
now = float(time.time() if now_ts is None else now_ts)
if self._last_sweep_ts is None:
dt_ms = float("nan")
else:
dt_ms = (now - self._last_sweep_ts) * 1000.0
self._last_sweep_ts = now
self._n_valid_hist.append((now, n_valid_cur))
while self._n_valid_hist and (now - self._n_valid_hist[0][0]) > 1.0:
self._n_valid_hist.popleft()
if self._n_valid_hist:
n_valid = float(sum(v for _t, v in self._n_valid_hist) / len(self._n_valid_hist))
else:
n_valid = float(n_valid_cur)
if n_valid_cur > 0:
vmin = float(np.nanmin(sweep))
vmax = float(np.nanmax(sweep))
mean = float(np.nanmean(sweep))
std = float(np.nanstd(sweep))
else:
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,
"mean": mean,
"std": std,
"dt_ms": dt_ms,
}
if pre_exp_sweep is not None:
info["pre_exp_sweep"] = pre_exp_sweep
return (sweep, info)
def finalize_current(self, now_ts: Optional[float] = None) -> Optional[SweepPacket]:
return self.finalize_arrays(self._xs, self._ys, self._cur_channels, now_ts=now_ts)

View File

@ -3,15 +3,12 @@
import sys
import threading
import time
from collections import deque
from queue import Full, Queue
from typing import Optional
import numpy as np
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD
from rfg_adc_plotter.io.sweep_parser_core import BinaryRecordStreamParser, SweepAssembler
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
from rfg_adc_plotter.types import SweepInfo, SweepPacket
from rfg_adc_plotter.types import SweepPacket
class SweepReader(threading.Thread):
@ -25,6 +22,8 @@ class SweepReader(threading.Thread):
stop_event: threading.Event,
fancy: bool = False,
bin_mode: bool = False,
logscale: bool = False,
debug: bool = False,
):
super().__init__(daemon=True)
self._port_path = port_path
@ -34,102 +33,15 @@ class SweepReader(threading.Thread):
self._src: Optional[SerialLineSource] = None
self._fancy = bool(fancy)
self._bin_mode = bool(bin_mode)
self._max_width: int = 0
self._sweep_idx: int = 0
self._last_sweep_ts: Optional[float] = None
self._n_valid_hist = deque()
@staticmethod
def _u16_to_i16(v: int) -> int:
"""Преобразование 16-bit слова в знаковое значение."""
return v - 0x10000 if (v & 0x8000) else v
self._logscale = bool(logscale)
self._debug = bool(debug)
self._assembler = SweepAssembler(fancy=self._fancy, logscale=self._logscale, debug=self._debug)
def _finalize_current(self, xs, ys, channels: Optional[set]):
if not xs:
packet = self._assembler.finalize_arrays(xs, ys, channels)
if packet is None:
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)
target_width = self._max_width if self._fancy else width
sweep = np.full((target_width,), np.nan, dtype=np.float32)
try:
idx = np.asarray(xs, dtype=np.int64)
vals = np.asarray(ys, dtype=np.float32)
sweep[idx] = vals
except Exception:
for x, y in zip(xs, ys):
if 0 <= x < target_width:
sweep[x] = float(y)
finite_pre = np.isfinite(sweep)
n_valid_cur = int(np.count_nonzero(finite_pre))
if self._fancy:
try:
known = ~np.isnan(sweep)
if np.any(known):
known_idx = np.nonzero(known)[0]
for i0, i1 in zip(known_idx[:-1], known_idx[1:]):
if i1 - i0 > 1:
avg = (sweep[i0] + sweep[i1]) * 0.5
sweep[i0 + 1 : i1] = avg
first_idx = int(known_idx[0])
last_idx = int(known_idx[-1])
if first_idx > 0:
sweep[:first_idx] = sweep[first_idx]
if last_idx < sweep.size - 1:
sweep[last_idx + 1 :] = sweep[last_idx]
except Exception:
pass
try:
m = float(np.nanmean(sweep))
if np.isfinite(m) and m < DATA_INVERSION_THRESHOLD:
sweep *= -1.0
except Exception:
pass
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")
else:
dt_ms = (now - self._last_sweep_ts) * 1000.0
self._last_sweep_ts = now
self._n_valid_hist.append((now, n_valid_cur))
while self._n_valid_hist and (now - self._n_valid_hist[0][0]) > 1.0:
self._n_valid_hist.popleft()
if self._n_valid_hist:
n_valid = float(sum(v for _t, v in self._n_valid_hist) / len(self._n_valid_hist))
else:
n_valid = float(n_valid_cur)
if n_valid_cur > 0:
vmin = float(np.nanmin(sweep))
vmax = float(np.nanmax(sweep))
mean = float(np.nanmean(sweep))
std = float(np.nanstd(sweep))
else:
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,
"mean": mean,
"std": std,
"dt_ms": dt_ms,
}
sweep, info = packet
try:
self._q.put_nowait((sweep, info))
except Full:
@ -149,6 +61,9 @@ class SweepReader(threading.Thread):
cur_channels: set[int] = set()
buf = bytearray()
_dbg_line_count = 0
_dbg_match_count = 0
_dbg_sweep_count = 0
while not self._stop.is_set():
data = chunk_reader.read_available()
if data:
@ -168,7 +83,12 @@ class SweepReader(threading.Thread):
if not line:
continue
_dbg_line_count += 1
if line.startswith(b"Sweep_start"):
if self._debug:
sys.stderr.write(f"[debug] ASCII строка #{_dbg_line_count}: Sweep_start → финализация свипа\n")
_dbg_sweep_count += 1
self._finalize_current(xs, ys, cur_channels)
xs.clear()
ys.clear()
@ -194,12 +114,35 @@ class SweepReader(threading.Thread):
x = int(parts[1], 10)
y = int(parts[2], 10)
except Exception:
if self._debug and _dbg_line_count <= 5:
hex_repr = " ".join(f"{b:02x}" for b in line[:16])
sys.stderr.write(
f"[debug] ASCII строка #{_dbg_line_count} ({len(line)} байт): {hex_repr}"
f"{'...' if len(line) > 16 else ''} → похожа на 's', но не парсится\n"
)
continue
_dbg_match_count += 1
if self._debug and _dbg_match_count <= 3:
sys.stderr.write(f"[debug] ASCII точка: ch={ch} x={x} y={y}\n")
if cur_channel is None:
cur_channel = ch
cur_channels.add(ch)
xs.append(x)
ys.append(y)
continue
if self._debug and _dbg_line_count <= 5:
hex_repr = " ".join(f"{b:02x}" for b in line[:16])
sys.stderr.write(
f"[debug] ASCII строка #{_dbg_line_count} ({len(line)} байт): {hex_repr}"
f"{'...' if len(line) > 16 else ''} → нет совпадения\n"
)
if self._debug and _dbg_line_count % 100 == 0:
sys.stderr.write(
f"[debug] ASCII статистика: строк={_dbg_line_count}, "
f"совпадений={_dbg_match_count}, свипов={_dbg_sweep_count}\n"
)
if len(buf) > 1_000_000:
del buf[:-262144]
@ -208,75 +151,68 @@ 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()
waiting_channel = False
waiting_first_point = False
point_word: Optional[int] = None
value_word: Optional[int] = None
parser = BinaryRecordStreamParser()
buf = bytearray()
# Поддерживаются оба 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
_dbg_sweep_count = 0
_dbg_point_count = 0
while not self._stop.is_set():
data = chunk_reader.read_available()
if data:
buf += data
events = parser.feed(data)
else:
time.sleep(0.0005)
continue
usable = len(buf) & ~1
if usable == 0:
continue
i = 0
while i < usable:
w = int(buf[i]) | (int(buf[i + 1]) << 8)
i += 2
if waiting_channel:
cur_channel = int(w)
cur_channels.add(cur_channel)
waiting_channel = False
waiting_first_point = True
continue
if w == 0xFFFF:
for ev in events:
tag = ev[0]
if tag == "start":
ch_new = int(ev[1])
if self._debug:
sys.stderr.write(f"[debug] BIN: старт свипа, ch={ch_new}\n")
_dbg_sweep_count += 1
self._finalize_current(xs, ys, cur_channels)
xs.clear()
ys.clear()
cur_channel = None
cur_channels.clear()
waiting_channel = True
waiting_first_point = False
point_word = None
value_word = None
cur_channel = ch_new
cur_channels.add(cur_channel)
continue
if point_word is None:
if waiting_first_point and (w == 0x0A0A or w == 0x000A):
continue
point_word = int(w)
waiting_first_point = False
continue
_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(ch_from_term))
xs.append(int(step))
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={float(value_i32):.3f}\n"
)
if value_word is None:
value_word = int(w)
continue
_dbg_byte_count = parser.bytes_consumed
_dbg_desync_count = parser.desync_count
is_point_end = (w == 0x000A) or ((w & 0x00FF) == 0x0A) or ((w >> 8) == 0x0A)
if is_point_end:
if cur_channel is not None:
cur_channels.add(cur_channel)
xs.append(point_word)
ys.append(self._u16_to_i16(value_word))
if self._debug and _dbg_byte_count > 0 and _dbg_byte_count % 4000 < 8:
sys.stderr.write(
f"[debug] BIN статистика: байт={_dbg_byte_count}, "
f"десинхронизаций={_dbg_desync_count}, точек={_dbg_point_count}, свипов={_dbg_sweep_count}\n"
)
point_word = None
value_word = None
del buf[:usable]
if len(buf) > 1_000_000:
del buf[:-262144]
if parser.buffered_size() > 1_000_000:
parser.clear_buffer_keep_tail(262_144)
self._finalize_current(xs, ys, cur_channels)
@ -291,6 +227,9 @@ class SweepReader(threading.Thread):
try:
chunk_reader = SerialChunkReader(self._src)
if self._debug:
mode_str = "бинарный (--bin)" if self._bin_mode else "ASCII (по умолчанию)"
sys.stderr.write(f"[debug] Режим парсера: {mode_str}\n")
if self._bin_mode:
self._run_binary_stream(chunk_reader)
else:

View File

@ -77,11 +77,35 @@ def build_parser() -> argparse.ArgumentParser:
default="projector",
help="Тип нормировки: projector (по огибающим в [-1000,+1000]) или simple (raw/calib)",
)
parser.add_argument(
"--ifft-complex-mode",
choices=["arccos", "diff"],
default="arccos",
help=(
"Режим реконструкции комплексного спектра перед IFFT: "
"arccos (phi=arccos(x), unwrap) или diff (sin(phi) через численную производную)"
),
)
parser.add_argument(
"--bin",
dest="bin_mode",
action="store_true",
help="Бинарный протокол: 16-bit поток, 0xFFFF+канал для старта свипа, точки point,value,'\\n'",
help=(
"Бинарный протокол (8 байт на запись, LE u16 слова): "
"старт свипа ff ff ff ff ff ff 0a [ch]; "
"точка step_u16 hi_u16 lo_u16 0a [ch]; "
"value=sign_ext((hi<<16)|lo); ch=0..N в старшем байте маркера"
),
)
parser.add_argument(
"--logscale",
action="store_true",
help="После поправки знака применять экспоненту LOG_EXP**x (LOG_EXP=2)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Отладочный вывод парсера: показывает принятые строки/слова и причины отсутствия свипов",
)
return parser

View File

@ -0,0 +1,300 @@
"""Преобразование свипа в IFFT-профиль по глубине (м).
Поддерживает несколько режимов восстановления комплексного спектра перед IFFT:
- ``arccos``: phi = arccos(x), continuous unwrap, z = exp(1j*phi)
- ``diff``: x ~= cos(phi), diff(x) -> sin(phi), z = cos + 1j*sin (с проекцией на единичную окружность)
"""
from __future__ import annotations
import logging
from typing import Optional
import numpy as np
from rfg_adc_plotter.constants import (
FREQ_MAX_GHZ,
FREQ_MIN_GHZ,
FREQ_SPAN_GHZ,
IFFT_LEN,
SPEED_OF_LIGHT_M_S,
)
logger = logging.getLogger(__name__)
_EPS = 1e-12
_TWO_PI = float(2.0 * np.pi)
_VALID_COMPLEX_MODES = {"arccos", "diff"}
def _fallback_depth_response(
size: int,
values: Optional[np.ndarray] = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Безопасный fallback для GUI/ring: всегда возвращает ненулевую длину."""
n = max(1, int(size))
depth = np.linspace(0.0, 1.0, n, dtype=np.float32)
if values is None:
return depth, np.zeros((n,), dtype=np.float32)
arr = np.asarray(values)
if arr.size == 0:
return depth, np.zeros((n,), dtype=np.float32)
if np.iscomplexobj(arr):
src = np.abs(arr)
else:
src = np.abs(np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0))
src = np.asarray(src, dtype=np.float32).ravel()
out = np.zeros((n,), dtype=np.float32)
take = min(n, src.size)
if take > 0:
out[:take] = src[:take]
return depth, out
def _normalize_complex_mode(mode: str) -> str:
m = str(mode).strip().lower()
if m not in _VALID_COMPLEX_MODES:
raise ValueError(f"Invalid complex reconstruction mode: {mode!r}")
return m
def build_ifft_time_axis_ns() -> np.ndarray:
"""Legacy helper: старая временная ось IFFT в наносекундах (фиксированная длина)."""
return (
np.arange(IFFT_LEN, dtype=np.float64) / (FREQ_SPAN_GHZ * 1e9) * 1e9
).astype(np.float32)
def build_frequency_axis_hz(sweep_width: int) -> np.ndarray:
"""Построить частотную сетку (Гц) для текущей длины свипа."""
n = int(sweep_width)
if n <= 0:
return np.zeros((0,), dtype=np.float64)
if n == 1:
return np.array([FREQ_MIN_GHZ * 1e9], dtype=np.float64)
return np.linspace(FREQ_MIN_GHZ * 1e9, FREQ_MAX_GHZ * 1e9, n, dtype=np.float64)
def normalize_trace_unit_range(x: np.ndarray) -> np.ndarray:
"""Signed-нормировка массива по max(abs(.)) в диапазон около [-1, 1]."""
arr = np.asarray(x, dtype=np.float64).ravel()
if arr.size == 0:
return arr
arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
amax = float(np.max(np.abs(arr)))
if (not np.isfinite(amax)) or amax <= _EPS:
return np.zeros_like(arr, dtype=np.float64)
return arr / amax
def normalize_sweep_for_phase(sweep: np.ndarray) -> np.ndarray:
"""Совместимый alias: нормировка свипа перед восстановлением фазы."""
return normalize_trace_unit_range(sweep)
def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray:
"""Непрерывно развернуть фазу, восстановленную через arccos.
Для каждой точки рассматриваются ветви ±phi + 2πk и выбирается кандидат,
ближайший к предыдущей фазе (nearest continuous).
"""
x = np.asarray(x_norm, dtype=np.float64).ravel()
if x.size == 0:
return np.zeros((0,), dtype=np.float64)
x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=-1.0)
x = np.clip(x, -1.0, 1.0)
phi0 = np.arccos(x)
out = np.empty_like(phi0, dtype=np.float64)
out[0] = float(phi0[0])
for i in range(1, phi0.size):
base_phi = float(phi0[i])
prev = float(out[i - 1])
best_cand: Optional[float] = None
best_key: Optional[tuple[float, float]] = None
for sign in (1.0, -1.0):
base = sign * base_phi
k_center = int(np.round((prev - base) / _TWO_PI))
for k in (k_center - 1, k_center, k_center + 1):
cand = base + _TWO_PI * float(k)
step = abs(cand - prev)
# Tie-break: при равенстве шага предпочесть больший кандидат.
key = (step, -cand)
if best_key is None or key < best_key:
best_key = key
best_cand = cand
out[i] = prev if best_cand is None else float(best_cand)
return out
def reconstruct_complex_spectrum_arccos(sweep: np.ndarray) -> np.ndarray:
"""Режим arccos: cos(phi) -> phi -> exp(i*phi)."""
x_norm = normalize_trace_unit_range(sweep)
if x_norm.size == 0:
return np.zeros((0,), dtype=np.complex128)
phi = unwrap_arccos_phase_continuous(np.clip(x_norm, -1.0, 1.0))
return np.exp(1j * phi).astype(np.complex128, copy=False)
def reconstruct_complex_spectrum_diff(sweep: np.ndarray) -> np.ndarray:
"""Режим diff: x~=cos(phi), diff(x)->sin(phi), z=cos+i*sin с проекцией на единичную окружность."""
cos_phi = normalize_trace_unit_range(sweep)
if cos_phi.size == 0:
return np.zeros((0,), dtype=np.complex128)
cos_phi = np.clip(cos_phi, -1.0, 1.0)
if cos_phi.size < 2:
sin_est = np.zeros_like(cos_phi, dtype=np.float64)
else:
d = np.gradient(cos_phi)
sin_est = normalize_trace_unit_range(d)
sin_est = np.clip(sin_est, -1.0, 1.0)
sin_est = normalize_trace_unit_range(d)
# mag = np.abs(sin_est)
# mask = mag > _EPS
# if np.any(mask):
# sin_est[mask] = sin_est[mask] / mag[mask]
z = cos_phi.astype(np.complex128, copy=False) + 1j * sin_est.astype(np.complex128, copy=False)
mag = np.abs(z)
z_unit = np.ones_like(z, dtype=np.complex128)
mask = mag > _EPS
if np.any(mask):
z_unit[mask] = z[mask] / mag[mask]
return z_unit
def reconstruct_complex_spectrum_from_real_trace(
sweep: np.ndarray,
*,
complex_mode: str = "arccos",
) -> np.ndarray:
"""Восстановить комплексный спектр из вещественного свипа в выбранном режиме."""
mode = _normalize_complex_mode(complex_mode)
if mode == "arccos":
return reconstruct_complex_spectrum_arccos(sweep)
if mode == "diff":
return reconstruct_complex_spectrum_diff(sweep)
raise ValueError(f"Unsupported complex reconstruction mode: {complex_mode!r}")
def perform_ifft_depth_response(
s_array: np.ndarray,
frequencies_hz: np.ndarray,
*,
axis: str = "abs",
start_hz: float | None = None,
stop_hz: float | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Frequency-to-depth conversion with zero-padding and frequency offset handling."""
try:
s_in = np.asarray(s_array, dtype=np.complex128).ravel()
f_in = np.asarray(frequencies_hz, dtype=np.float64).ravel()
m = min(s_in.size, f_in.size)
if m < 2:
raise ValueError("Not enough points")
s = s_in[:m]
f = f_in[:m]
lo = float(FREQ_MIN_GHZ * 1e9 if start_hz is None else start_hz)
hi = float(FREQ_MAX_GHZ * 1e9 if stop_hz is None else stop_hz)
if hi < lo:
lo, hi = hi, lo
mask = (
np.isfinite(f)
& np.isfinite(np.real(s))
& np.isfinite(np.imag(s))
& (f >= lo)
& (f <= hi)
)
f = f[mask]
s = s[mask]
n = int(f.size)
if n < 2:
raise ValueError("Not enough frequency points after filtering")
if np.any(np.diff(f) <= 0.0):
raise ValueError("Non-increasing frequency grid")
df = float((f[-1] - f[0]) / (n - 1))
if not np.isfinite(df) or df <= 0.0:
raise ValueError("Invalid frequency step")
k0 = int(np.round(float(f[0]) / df))
if k0 < 0:
raise ValueError("Negative frequency offset index")
min_len = int(2 * (k0 + n - 1))
if min_len <= 0:
raise ValueError("Invalid FFT length")
n_fft = 1 << int(np.ceil(np.log2(float(min_len))))
dt = 1.0 / (n_fft * df)
t_sec = np.arange(n_fft, dtype=np.float64) * dt
h = np.zeros((n_fft,), dtype=np.complex128)
end = k0 + n
if end > n_fft:
raise ValueError("Spectrum placement exceeds FFT buffer")
h[k0:end] = s
y = np.fft.ifft(h)
depth_m = t_sec * SPEED_OF_LIGHT_M_S
axis_name = str(axis).strip().lower()
if axis_name == "abs":
y_fin = np.abs(y)
elif axis_name == "real":
y_fin = np.real(y)
elif axis_name == "imag":
y_fin = np.imag(y)
elif axis_name == "phase":
y_fin = np.angle(y)
else:
raise ValueError(f"Invalid axis parameter: {axis!r}")
return depth_m.astype(np.float32, copy=False), np.asarray(y_fin, dtype=np.float32)
except Exception as exc: # noqa: BLE001
logger.error("IFFT depth response failed: %r", exc)
return _fallback_depth_response(np.asarray(s_array).size, np.asarray(s_array))
def compute_ifft_profile_from_sweep(
sweep: Optional[np.ndarray],
*,
complex_mode: str = "arccos",
) -> tuple[np.ndarray, np.ndarray]:
"""Высокоуровневый pipeline: sweep -> complex spectrum -> IFFT(abs) depth profile."""
if sweep is None:
return _fallback_depth_response(1, None)
try:
s = np.asarray(sweep, dtype=np.float64).ravel()
if s.size == 0:
return _fallback_depth_response(1, None)
freqs_hz = build_frequency_axis_hz(s.size)
s_complex = reconstruct_complex_spectrum_from_real_trace(s, complex_mode=complex_mode)
depth_m, y = perform_ifft_depth_response(s_complex, freqs_hz, axis="abs")
n = min(depth_m.size, y.size)
if n <= 0:
return _fallback_depth_response(s.size, s)
return depth_m[:n].astype(np.float32, copy=False), np.maximum(y[:n], 1e-12).astype(np.float32, copy=False) # log10 для лучшей визуализации в водопаде
except Exception as exc: # noqa: BLE001
logger.error("compute_ifft_profile_from_sweep failed: %r", exc)
return _fallback_depth_response(np.asarray(sweep).size if sweep is not None else 1, sweep)
def compute_ifft_db_profile(sweep: Optional[np.ndarray]) -> np.ndarray:
"""Legacy wrapper (deprecated name): возвращает линейный |IFFT| профиль."""
_depth_m, y = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos")
return y

View File

@ -0,0 +1,415 @@
"""Явный pipeline предобработки свипов перед помещением в RingBuffer."""
from __future__ import annotations
from dataclasses import dataclass
import os
from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.io.capture_reference_loader import (
CaptureParseSummary,
aggregate_capture_reference,
detect_reference_file_format,
load_capture_sweeps,
)
from rfg_adc_plotter.processing.normalizer import (
build_calib_envelopes,
normalize_by_calib,
normalize_by_envelope,
)
DEFAULT_CALIB_ENVELOPE_PATH = "calib_envelope.npy"
DEFAULT_BACKGROUND_PATH = "background.npy"
def _normalize_path(path: str) -> str:
return str(path).strip()
def _normalize_save_npy_path(path: str) -> str:
p = _normalize_path(path)
if not p:
return p
_root, ext = os.path.splitext(p)
if ext:
return p
return f"{p}.npy"
def _summary_for_npy(path: str) -> CaptureParseSummary:
return CaptureParseSummary(
path=path,
format="npy",
sweeps_total=0,
sweeps_valid=0,
channels_seen=tuple(),
dominant_width=None,
dominant_n_valid=None,
aggregation="median",
warnings=tuple(),
)
@dataclass(frozen=True)
class SweepProcessingResult:
"""Результат предобработки одного свипа."""
processed_sweep: np.ndarray
normalized_sweep: Optional[np.ndarray]
calibration_applied: bool
background_applied: bool
calibration_source: str # off|live|npy|capture
background_source: str # off|npy|capture(raw)|capture(raw->calib)
is_calibration_reference: bool
stage_trace: Tuple[str, ...]
class SweepPreprocessor:
"""Управляет калибровкой/фоном и применяет их к входному свипу."""
def __init__(
self,
norm_type: str = "projector",
calib_envelope_path: str = DEFAULT_CALIB_ENVELOPE_PATH,
background_path: str = DEFAULT_BACKGROUND_PATH,
auto_save_live_calib_envelope: bool = True,
):
self.norm_type = str(norm_type).strip().lower() or "projector"
self.calib_enabled = False
self.calib_mode = "live" # live | file
self.background_enabled = False
self.auto_save_live_calib_envelope = bool(auto_save_live_calib_envelope)
self.calib_envelope_path = _normalize_path(calib_envelope_path)
self.background_path = _normalize_path(background_path)
self.last_calib_sweep: Optional[np.ndarray] = None
self.calib_file_envelope: Optional[np.ndarray] = None
# background — в текущем домене вычитания (raw или normalized), UI использует для preview/state
self.background: Optional[np.ndarray] = None
# raw background loaded from capture file; преобразуется на лету при активной калибровке
self.background_raw_capture: Optional[np.ndarray] = None
# Источники и метаданные загрузки
self.calib_external_source_type: str = "none" # none|npy|capture
self.background_source_type: str = "none" # none|npy_processed|capture_raw
self.calib_reference_summary: Optional[CaptureParseSummary] = None
self.background_reference_summary: Optional[CaptureParseSummary] = None
self.last_reference_error: str = ""
# Параметры офлайн-парсинга capture (должны совпадать с live parser по настройке UI)
self.capture_fancy: bool = False
self.capture_logscale: bool = False
self.reference_aggregation_method: str = "median"
# ---- Конфигурация ----
def set_calib_mode(self, mode: str):
m = str(mode).strip().lower()
self.calib_mode = "file" if m == "file" else "live"
def set_calib_enabled(self, enabled: bool):
self.calib_enabled = bool(enabled)
def set_background_enabled(self, enabled: bool):
self.background_enabled = bool(enabled)
def set_capture_parse_options(self, *, fancy: Optional[bool] = None, logscale: Optional[bool] = None):
if fancy is not None:
self.capture_fancy = bool(fancy)
if logscale is not None:
self.capture_logscale = bool(logscale)
def set_calib_envelope_path(self, path: str):
p = _normalize_path(path)
if p:
if p != self.calib_envelope_path:
self.calib_file_envelope = None
if self.calib_external_source_type in ("npy", "capture"):
self.calib_external_source_type = "none"
self.calib_reference_summary = None
self.calib_envelope_path = p
def set_background_path(self, path: str):
p = _normalize_path(path)
if p:
if p != self.background_path:
self.background = None
self.background_raw_capture = None
self.background_source_type = "none"
self.background_reference_summary = None
self.background_path = p
def has_calib_envelope_file(self) -> bool:
return bool(self.calib_envelope_path) and os.path.isfile(self.calib_envelope_path)
def has_background_file(self) -> bool:
return bool(self.background_path) and os.path.isfile(self.background_path)
# ---- Загрузка/сохранение .npy ----
def _save_array(self, arr: np.ndarray, current_path: str, path: Optional[str]) -> str:
target = _normalize_save_npy_path(path if path is not None else current_path)
if not target:
raise ValueError("Пустой путь сохранения")
np.save(target, arr)
return target
def save_calib_envelope(self, path: Optional[str] = None) -> bool:
"""Сохранить огибающую из последнего live-калибровочного свипа (экспорт .npy)."""
if self.last_calib_sweep is None:
return False
try:
_lower, upper = build_calib_envelopes(self.last_calib_sweep)
self.calib_envelope_path = self._save_array(upper, self.calib_envelope_path, path)
self.last_reference_error = ""
return True
except Exception as exc:
self.last_reference_error = f"save calib envelope failed: {exc}"
return False
def save_background(self, sweep_for_ring: Optional[np.ndarray], path: Optional[str] = None) -> bool:
"""Сохранить текущий свип (в текущем домене обработки) как .npy-фон."""
if sweep_for_ring is None:
return False
try:
bg = np.asarray(sweep_for_ring, dtype=np.float32).copy()
self.background_path = self._save_array(bg, self.background_path, path)
self.background = bg
self.background_raw_capture = None
self.background_source_type = "npy_processed"
self.background_reference_summary = _summary_for_npy(self.background_path)
self.last_reference_error = ""
return True
except Exception as exc:
self.last_reference_error = f"save background failed: {exc}"
return False
# ---- Загрузка эталонов (.npy или capture) ----
def _detect_source_kind(self, path: str, source_kind: str) -> Optional[str]:
sk = str(source_kind).strip().lower() or "auto"
if sk == "auto":
return detect_reference_file_format(path)
if sk in ("npy", "bin_capture", "capture"):
return "bin_capture" if sk == "capture" else sk
return None
def _load_npy_vector(self, path: str) -> np.ndarray:
arr = np.load(path)
return np.asarray(arr, dtype=np.float32).reshape(-1)
def load_calib_reference(
self,
path: Optional[str] = None,
*,
source_kind: str = "auto",
method: str = "median",
) -> bool:
"""Загрузить калибровку из .npy (огибающая) или raw capture файла."""
if path is not None:
self.set_calib_envelope_path(path)
p = self.calib_envelope_path
if not p or not os.path.isfile(p):
self.last_reference_error = f"Файл калибровки не найден: {p}"
return False
fmt = self._detect_source_kind(p, source_kind)
if fmt is None:
self.last_reference_error = f"Неизвестный формат файла калибровки: {p}"
return False
try:
if fmt == "npy":
env = self._load_npy_vector(p)
self.calib_file_envelope = env
self.calib_external_source_type = "npy"
self.calib_reference_summary = _summary_for_npy(p)
self.last_reference_error = ""
return True
sweeps = load_capture_sweeps(p, fancy=self.capture_fancy, logscale=self.capture_logscale)
vec, summary = aggregate_capture_reference(
sweeps,
channel=0,
method=method or self.reference_aggregation_method,
path=p,
)
_lower, upper = build_calib_envelopes(vec)
self.calib_file_envelope = np.asarray(upper, dtype=np.float32)
self.calib_external_source_type = "capture"
self.calib_reference_summary = summary
self.last_reference_error = ""
return True
except Exception as exc:
self.last_reference_error = f"Ошибка загрузки калибровки: {exc}"
return False
def load_background_reference(
self,
path: Optional[str] = None,
*,
source_kind: str = "auto",
method: str = "median",
) -> bool:
"""Загрузить фон из .npy (готовый домен) или raw capture файла."""
if path is not None:
self.set_background_path(path)
p = self.background_path
if not p or not os.path.isfile(p):
self.last_reference_error = f"Файл фона не найден: {p}"
return False
fmt = self._detect_source_kind(p, source_kind)
if fmt is None:
self.last_reference_error = f"Неизвестный формат файла фона: {p}"
return False
try:
if fmt == "npy":
bg = self._load_npy_vector(p)
self.background = bg
self.background_raw_capture = None
self.background_source_type = "npy_processed"
self.background_reference_summary = _summary_for_npy(p)
self.last_reference_error = ""
return True
sweeps = load_capture_sweeps(p, fancy=self.capture_fancy, logscale=self.capture_logscale)
vec, summary = aggregate_capture_reference(
sweeps,
channel=0,
method=method or self.reference_aggregation_method,
path=p,
)
self.background_raw_capture = np.asarray(vec, dtype=np.float32)
# Для UI/preview текущий background отражает текущий домен (пока raw по умолчанию).
self.background = self.background_raw_capture
self.background_source_type = "capture_raw"
self.background_reference_summary = summary
self.last_reference_error = ""
return True
except Exception as exc:
self.last_reference_error = f"Ошибка загрузки фона: {exc}"
return False
# Совместимые обертки для старого API (строго .npy)
def load_calib_envelope(self, path: Optional[str] = None) -> bool:
target = path if path is not None else self.calib_envelope_path
return self.load_calib_reference(target, source_kind="npy")
def load_background(self, path: Optional[str] = None) -> bool:
target = path if path is not None else self.background_path
return self.load_background_reference(target, source_kind="npy")
# ---- Нормировка / фон ----
def _normalize_against_active_reference(self, raw: np.ndarray) -> Tuple[Optional[np.ndarray], bool, str]:
if not self.calib_enabled:
return None, False, "off"
if self.calib_mode == "file":
if self.calib_file_envelope is None:
return None, False, "off"
src = "capture" if self.calib_external_source_type == "capture" else "npy"
return normalize_by_envelope(raw, self.calib_file_envelope), True, src
if self.last_calib_sweep is None:
return None, False, "off"
return normalize_by_calib(raw, self.last_calib_sweep, self.norm_type), True, "live"
def _transform_raw_background_for_current_domain(self, calib_applied: bool) -> Optional[np.ndarray]:
if self.background_raw_capture is None:
return None
if not calib_applied:
return self.background_raw_capture
# Порядок pipeline фиксирован: raw -> calibration -> background -> IFFT.
# Поэтому raw-фон из capture нужно привести в тот же домен, что и текущий sweep_for_ring.
if self.calib_mode == "file" and self.calib_file_envelope is not None:
return normalize_by_envelope(self.background_raw_capture, self.calib_file_envelope)
if self.calib_mode == "live" and self.last_calib_sweep is not None:
return normalize_by_calib(self.background_raw_capture, self.last_calib_sweep, self.norm_type)
return None
def _effective_background(self, calib_applied: bool) -> Tuple[Optional[np.ndarray], str]:
if self.background_source_type == "capture_raw":
bg = self._transform_raw_background_for_current_domain(calib_applied)
if bg is None:
return None, "capture(raw->calib:missing-calib)"
self.background = np.asarray(bg, dtype=np.float32)
return self.background, ("capture(raw->calib)" if calib_applied else "capture(raw)")
if self.background_source_type == "npy_processed" and self.background is not None:
return self.background, "npy"
if self.background is not None:
return self.background, "unknown"
return None, "off"
def _subtract_background(self, sweep: np.ndarray, calib_applied: bool) -> Tuple[np.ndarray, bool, str]:
if not self.background_enabled:
return sweep, False, "off"
bg, bg_src = self._effective_background(calib_applied)
if bg is None:
return sweep, False, f"{bg_src}:missing"
out = np.asarray(sweep, dtype=np.float32).copy()
w = min(out.size, bg.size)
if w > 0:
out[:w] -= bg[:w]
return out, True, bg_src
def process(self, sweep: np.ndarray, channel: int, update_references: bool = True) -> SweepProcessingResult:
"""Применить к свипу калибровку/фон и вернуть явные этапы обработки."""
raw = np.asarray(sweep, dtype=np.float32)
ch = int(channel)
if ch == 0:
if update_references:
self.last_calib_sweep = raw
if self.auto_save_live_calib_envelope:
self.save_calib_envelope()
# ch0 всегда остаётся live-калибровочной ссылкой (raw), но при file-калибровке
# можем применять её и к ch0 для отображения/обработки независимо от канала.
calib_applied = False
calib_source = "off"
normalized: Optional[np.ndarray] = None
if self.calib_enabled and self.calib_mode == "file":
normalized, calib_applied, calib_source = self._normalize_against_active_reference(raw)
base = normalized if normalized is not None else raw
processed, bg_applied, bg_source = self._subtract_background(base, calib_applied=calib_applied)
stages = ["parsed_sweep", "raw_sweep", "ch0_live_calibration_reference"]
stages.append(f"calibration_{calib_source}" if calib_applied else "calibration_off")
stages.append(f"background_{bg_source}" if bg_applied else "background_off")
stages.extend(["ring_buffer", "ifft_db"])
return SweepProcessingResult(
processed_sweep=processed,
normalized_sweep=normalized,
calibration_applied=calib_applied,
background_applied=bg_applied,
calibration_source=calib_source if calib_applied else "off",
background_source=bg_source if bg_applied else "off",
is_calibration_reference=True,
stage_trace=tuple(stages),
)
normalized, calib_applied, calib_source = self._normalize_against_active_reference(raw)
base = normalized if normalized is not None else raw
processed, bg_applied, bg_source = self._subtract_background(base, calib_applied)
stages = ["parsed_sweep", "raw_sweep"]
stages.append(f"calibration_{calib_source}" if calib_applied else "calibration_off")
stages.append(f"background_{bg_source}" if bg_applied else "background_off")
stages.extend(["ring_buffer", "ifft_db"])
return SweepProcessingResult(
processed_sweep=processed,
normalized_sweep=normalized,
calibration_applied=calib_applied,
background_applied=bg_applied,
calibration_source=calib_source if calib_applied else "off",
background_source=bg_source if bg_applied else "off",
is_calibration_reference=False,
stage_trace=tuple(stages),
)

View File

@ -1,21 +1,20 @@
"""Состояние приложения: текущие свипы и настройки калибровки/нормировки."""
import os
from queue import Empty, Queue
from typing import Any, Dict, Mapping, Optional
from typing import Any, Mapping, Optional
import numpy as np
from rfg_adc_plotter.processing.normalizer import (
build_calib_envelopes,
normalize_by_calib,
normalize_by_envelope,
from rfg_adc_plotter.processing.pipeline import (
DEFAULT_BACKGROUND_PATH,
DEFAULT_CALIB_ENVELOPE_PATH,
SweepPreprocessor,
)
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepInfo, SweepPacket
CALIB_ENVELOPE_PATH = "calib_envelope.npy"
BACKGROUND_PATH = "background.npy"
CALIB_ENVELOPE_PATH = DEFAULT_CALIB_ENVELOPE_PATH
BACKGROUND_PATH = DEFAULT_BACKGROUND_PATH
def format_status(data: Mapping[str, Any]) -> str:
@ -34,126 +33,277 @@ def format_status(data: Mapping[str, Any]) -> str:
return f"{fv:.3g}"
return f"{fv:.3f}".rstrip("0").rstrip(".")
parts = [f"{k}:{_fmt(v)}" for k, v in data.items()]
parts = [f"{k}:{_fmt(v)}" for k, v in data.items() if k != "pre_exp_sweep"]
return " ".join(parts)
class AppState:
"""Весь изменяемый GUI-state: текущие данные, калибровка, настройки.
Методы drain_queue и set_calib_enabled заменяют одноимённые closures
с nonlocal из оригинального кода.
"""
"""Весь изменяемый GUI-state: текущие данные + pipeline предобработки."""
def __init__(self, norm_type: str = "projector"):
self.current_sweep_pre_exp: Optional[np.ndarray] = None
self.current_sweep_post_exp: Optional[np.ndarray] = None
self.current_sweep_processed: Optional[np.ndarray] = None
self.current_sweep_raw: Optional[np.ndarray] = None
self.current_sweep_norm: Optional[np.ndarray] = None
self.last_calib_sweep: Optional[np.ndarray] = None
self.current_info: Optional[SweepInfo] = None
self.calib_enabled: bool = False
self.norm_type: str = norm_type
# "live" — нормировка по текущему ch0-свипу; "file" — по огибающей из файла
self.calib_mode: str = "live"
self.calib_file_envelope: Optional[np.ndarray] = None
# Вычет фона
self.background: Optional[np.ndarray] = None
self.background_enabled: bool = False
self.norm_type: str = str(norm_type).strip().lower()
self.preprocessor = SweepPreprocessor(norm_type=self.norm_type)
self._last_sweep_for_ring: Optional[np.ndarray] = None
self._last_stage_trace: tuple[str, ...] = tuple()
def _normalize(self, raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
if self.calib_mode == "file" and self.calib_file_envelope is not None:
return normalize_by_envelope(raw, self.calib_file_envelope)
return normalize_by_calib(raw, calib, self.norm_type)
def configure_capture_import(self, *, fancy: Optional[bool] = None, logscale: Optional[bool] = None):
self.preprocessor.set_capture_parse_options(fancy=fancy, logscale=logscale)
def save_calib_envelope(self, path: str = CALIB_ENVELOPE_PATH) -> bool:
"""Вычислить огибающую из last_calib_sweep и сохранить в файл.
# ---- Свойства pipeline (для совместимости с GUI) ----
@property
def calib_enabled(self) -> bool:
return self.preprocessor.calib_enabled
Возвращает True при успехе.
"""
if self.last_calib_sweep is None:
return False
try:
_lower, upper = build_calib_envelopes(self.last_calib_sweep)
np.save(path, upper)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось сохранить огибающую: {exc}\n")
return False
@property
def calib_mode(self) -> str:
return self.preprocessor.calib_mode
def load_calib_envelope(self, path: str = CALIB_ENVELOPE_PATH) -> bool:
"""Загрузить огибающую из файла.
@property
def calib_file_envelope(self) -> Optional[np.ndarray]:
return self.preprocessor.calib_file_envelope
Возвращает True при успехе.
"""
if not os.path.isfile(path):
return False
try:
env = np.load(path)
self.calib_file_envelope = np.asarray(env, dtype=np.float32)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось загрузить огибающую: {exc}\n")
return False
@property
def last_calib_sweep(self) -> Optional[np.ndarray]:
return self.preprocessor.last_calib_sweep
@property
def background(self) -> Optional[np.ndarray]:
return self.preprocessor.background
@property
def background_enabled(self) -> bool:
return self.preprocessor.background_enabled
@property
def calib_source_type(self) -> str:
return self.preprocessor.calib_external_source_type
@property
def background_source_type(self) -> str:
return self.preprocessor.background_source_type
@property
def calib_reference_summary(self):
return self.preprocessor.calib_reference_summary
@property
def background_reference_summary(self):
return self.preprocessor.background_reference_summary
@property
def last_reference_error(self) -> str:
return self.preprocessor.last_reference_error
@property
def calib_envelope_path(self) -> str:
return self.preprocessor.calib_envelope_path
@property
def background_path(self) -> str:
return self.preprocessor.background_path
# ---- Управление файлами калибровки/фона ----
def set_calib_envelope_path(self, path: str):
self.preprocessor.set_calib_envelope_path(path)
self._refresh_current_processed()
def set_background_path(self, path: str):
self.preprocessor.set_background_path(path)
self._refresh_current_processed()
def has_calib_envelope_file(self) -> bool:
return self.preprocessor.has_calib_envelope_file()
def has_background_file(self) -> bool:
return self.preprocessor.has_background_file()
def save_calib_envelope(self, path: Optional[str] = None) -> bool:
return self.preprocessor.save_calib_envelope(path)
def load_calib_reference(self, path: Optional[str] = None) -> bool:
ok = self.preprocessor.load_calib_reference(path)
if ok:
self._refresh_current_processed()
return ok
def load_calib_envelope(self, path: Optional[str] = None) -> bool:
return self.load_calib_reference(path)
def set_calib_mode(self, mode: str):
"""Переключить режим калибровки: 'live' или 'file'."""
self.calib_mode = mode
self.preprocessor.set_calib_mode(mode)
self._refresh_current_processed()
def save_background(self, path: str = BACKGROUND_PATH) -> bool:
"""Сохранить текущий sweep_for_ring как фоновый спектр.
def save_background(self, path: Optional[str] = None) -> bool:
return self.preprocessor.save_background(self._last_sweep_for_ring, path)
Сохраняет последний свип, который был записан в ринг-буфер
(нормированный, если калибровка включена, иначе сырой).
Возвращает True при успехе.
"""
if self._last_sweep_for_ring is None:
return False
try:
np.save(path, self._last_sweep_for_ring)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось сохранить фон: {exc}\n")
return False
def load_background_reference(self, path: Optional[str] = None) -> bool:
ok = self.preprocessor.load_background_reference(path)
if ok:
self._refresh_current_processed()
return ok
def load_background(self, path: str = BACKGROUND_PATH) -> bool:
"""Загрузить фоновый спектр из файла.
Возвращает True при успехе.
"""
if not os.path.isfile(path):
return False
try:
bg = np.load(path)
self.background = np.asarray(bg, dtype=np.float32)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось загрузить фон: {exc}\n")
return False
def load_background(self, path: Optional[str] = None) -> bool:
return self.load_background_reference(path)
def set_background_enabled(self, enabled: bool):
"""Включить/выключить вычет фона."""
self.background_enabled = enabled
self.preprocessor.set_background_enabled(enabled)
self._refresh_current_processed()
def set_calib_enabled(self, enabled: bool):
"""Включить/выключить режим калибровки, пересчитать norm-свип."""
self.calib_enabled = enabled
if self.calib_enabled and self.current_sweep_raw is not None:
if self.calib_mode == "file" and self.calib_file_envelope is not None:
self.current_sweep_norm = normalize_by_envelope(
self.current_sweep_raw, self.calib_file_envelope
)
elif self.calib_mode == "live" and self.last_calib_sweep is not None:
self.current_sweep_norm = self._normalize(
self.current_sweep_raw, self.last_calib_sweep
)
else:
self.current_sweep_norm = None
self.preprocessor.set_calib_enabled(enabled)
self._refresh_current_processed()
# ---- Вспомогательные методы для UI ----
def _current_channel(self) -> Optional[int]:
if not isinstance(self.current_info, dict):
return None
try:
return int(self.current_info.get("ch", 0))
except Exception:
return 0
def _apply_result_to_current(self, result) -> None:
self._last_stage_trace = tuple(result.stage_trace)
if result.is_calibration_reference:
self.current_sweep_norm = None
elif result.calibration_applied or result.background_applied:
self.current_sweep_norm = result.processed_sweep
else:
self.current_sweep_norm = None
self.current_sweep_processed = result.processed_sweep
self._last_sweep_for_ring = result.processed_sweep
def _refresh_current_processed(self):
if self.current_sweep_raw is None:
self.current_sweep_norm = None
self.current_sweep_processed = None
self._last_stage_trace = tuple()
return
ch = self._current_channel() or 0
result = self.preprocessor.process(self.current_sweep_raw, ch, update_references=False)
self._apply_result_to_current(result)
def format_pipeline_status(self) -> str:
"""Краткое описание pipeline для UI: от распарсенного свипа до IFFT."""
ch = self._current_channel()
if ch is None:
ch_txt = "?"
else:
ch_txt = str(ch)
reader_stage = "log-exp" if self.current_sweep_pre_exp is not None else "linear"
if ch == 0:
file_calib_applies = (
self.calib_enabled
and self.calib_mode == "file"
and self.calib_file_envelope is not None
)
if self.calib_enabled and self.calib_mode == "file":
calib_stage = self.format_calib_source_status()
else:
calib_stage = "calib[off]"
if not self.background_enabled:
bg_stage = "bg[off]"
elif self.background_source_type == "capture_raw":
if self.background is None:
bg_stage = (
"bg[capture(raw->calib):missing]"
if file_calib_applies
else "bg[capture(raw):missing]"
)
else:
bg_stage = "bg[capture(raw->calib)]" if file_calib_applies else "bg[capture(raw)]"
elif self.background_source_type == "npy_processed":
bg_stage = "bg[npy]" if self.background is not None else "bg[npy:missing]"
else:
bg_stage = "bg[sub]" if self.background is not None else "bg[missing]"
return (
f"pipeline ch{ch_txt}: parsed -> {reader_stage} -> raw -> "
f"live-calib-ref -> {calib_stage} -> {bg_stage} -> ring -> IFFT(abs, depth_m)"
)
calib_stage = self.format_calib_source_status()
bg_stage = self.format_background_source_status()
return (
f"pipeline ch{ch_txt}: parsed -> {reader_stage} -> raw -> "
f"{calib_stage} -> {bg_stage} -> ring -> IFFT(abs, depth_m)"
)
def _format_summary(self, summary) -> str:
if summary is None:
return ""
parts: list[str] = []
if getattr(summary, "sweeps_valid", 0) or getattr(summary, "sweeps_total", 0):
parts.append(f"valid:{summary.sweeps_valid}/{summary.sweeps_total}")
if getattr(summary, "dominant_width", None) is not None:
parts.append(f"w:{summary.dominant_width}")
chs = getattr(summary, "channels_seen", tuple())
if chs:
parts.append("chs:" + ",".join(str(v) for v in chs))
warns = getattr(summary, "warnings", tuple())
if warns:
parts.append(f"warn:{warns[0]}")
return " ".join(parts)
def format_calib_source_status(self) -> str:
if not self.calib_enabled:
return "calib[off]"
if self.calib_mode == "live":
return "calib[live]" if self.last_calib_sweep is not None else "calib[live:no-ref]"
if self.calib_file_envelope is None:
return "calib[file:missing]"
if self.calib_source_type == "capture":
return "calib[capture]"
if self.calib_source_type == "npy":
return "calib[npy]"
return "calib[file]"
def format_background_source_status(self) -> str:
if not self.background_enabled:
return "bg[off]"
src = self.background_source_type
if src == "capture_raw":
if self.calib_enabled:
can_map = (
(self.calib_mode == "file" and self.calib_file_envelope is not None)
or (self.calib_mode == "live" and self.last_calib_sweep is not None)
)
if not can_map:
return "bg[capture(raw->calib):missing]"
if self.background is None:
return "bg[capture(raw->calib):missing]"
return "bg[capture(raw->calib)]" if self.calib_enabled else "bg[capture(raw)]"
if src == "npy_processed":
return "bg[npy]" if self.background is not None else "bg[npy:missing]"
if self.background is not None:
return "bg[sub]"
return "bg[missing]"
def format_reference_status(self) -> str:
parts: list[str] = []
calib_s = self._format_summary(self.calib_reference_summary)
if calib_s:
parts.append(f"calib[{calib_s}]")
bg_s = self._format_summary(self.background_reference_summary)
if bg_s:
parts.append(f"bg[{bg_s}]")
if self.last_reference_error:
parts.append(f"err:{self.last_reference_error}")
return " | ".join(parts)
def format_stage_trace(self) -> str:
if not self._last_stage_trace:
return ""
return " -> ".join(self._last_stage_trace)
def drain_queue(self, q: "Queue[SweepPacket]", ring: RingBuffer) -> int:
"""Вытащить все ожидающие свипы из очереди, обновить state и ring.
@ -167,45 +317,23 @@ class AppState:
except Empty:
break
drained += 1
self.current_sweep_raw = s
self.current_info = info
ch = 0
self.current_sweep_raw = s
self.current_sweep_post_exp = s
self.current_info = info
pre_exp = info.get("pre_exp_sweep") if isinstance(info, dict) else None
self.current_sweep_pre_exp = pre_exp if isinstance(pre_exp, np.ndarray) else None
try:
ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0
except Exception:
ch = 0
# Канал 0 — опорный (калибровочный) свип
if ch == 0:
self.last_calib_sweep = s
self.save_calib_envelope()
self.current_sweep_norm = None
sweep_for_ring = s
self._last_sweep_for_ring = sweep_for_ring
else:
can_normalize = self.calib_enabled and (
(self.calib_mode == "file" and self.calib_file_envelope is not None)
or (self.calib_mode == "live" and self.last_calib_sweep is not None)
)
if can_normalize:
calib_ref = self.last_calib_sweep if self.last_calib_sweep is not None else s
self.current_sweep_norm = self._normalize(s, calib_ref)
sweep_for_ring = self.current_sweep_norm
else:
self.current_sweep_norm = None
sweep_for_ring = s
result = self.preprocessor.process(s, ch, update_references=True)
self._apply_result_to_current(result)
# Вычет фона (в том же домене что и sweep_for_ring)
if self.background_enabled and self.background is not None and ch != 0:
w = min(sweep_for_ring.size, self.background.size)
sweep_for_ring = sweep_for_ring.copy()
sweep_for_ring[:w] -= self.background[:w]
self.current_sweep_norm = sweep_for_ring
self._last_sweep_for_ring = sweep_for_ring
ring.ensure_init(s.size)
ring.push(sweep_for_ring)
ring.push(result.processed_sweep)
return drained
def format_channel_label(self) -> str:

View File

@ -6,13 +6,12 @@ from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import (
FFT_LEN,
FREQ_SPAN_GHZ,
IFFT_LEN,
SWEEP_LEN,
FREQ_MAX_GHZ,
FREQ_MIN_GHZ,
WF_WIDTH,
ZEROS_LOW,
ZEROS_MID,
)
from rfg_adc_plotter.processing.fourier import (
compute_ifft_profile_from_sweep,
)
@ -25,7 +24,9 @@ class RingBuffer:
def __init__(self, max_sweeps: int):
self.max_sweeps = max_sweeps
self.fft_bins = IFFT_LEN # = 1953 (полная длина IFFT-результата)
# Размер IFFT-профиля теперь динамический и определяется по первому успешному свипу.
self.fft_bins = 0
self.fft_complex_mode: str = "arccos"
# Инициализируются при первом свипе (ensure_init)
self.ring: Optional[np.ndarray] = None # (max_sweeps, WF_WIDTH)
@ -34,7 +35,7 @@ class RingBuffer:
self.head: int = 0
self.width: Optional[int] = None
self.x_shared: Optional[np.ndarray] = None
self.fft_time_axis: Optional[np.ndarray] = None # временная ось IFFT в нс
self.fft_depth_axis_m: Optional[np.ndarray] = None # ось глубины IFFT в метрах
self.y_min_fft: Optional[float] = None
self.y_max_fft: Optional[float] = None
# FFT последнего свипа (для отображения без повторного вычисления)
@ -44,21 +45,41 @@ class RingBuffer:
def is_ready(self) -> bool:
return self.ring is not None
@property
def fft_time_axis(self) -> Optional[np.ndarray]:
"""Legacy alias: старое имя поля (раньше было время в нс, теперь глубина в м)."""
return self.fft_depth_axis_m
def set_fft_complex_mode(self, mode: str) -> bool:
"""Выбрать режим реконструкции комплексного спектра для IFFT.
Возвращает True, если режим изменился (и FFT-буфер был сброшен).
"""
m = str(mode).strip().lower()
if m not in ("arccos", "diff"):
raise ValueError(f"Unsupported IFFT complex mode: {mode!r}")
if m == self.fft_complex_mode:
return False
self.fft_complex_mode = m
# Сбрасываем только FFT-зависимые структуры. Сырые ряды сохраняем.
self.ring_fft = None
self.fft_depth_axis_m = None
self.fft_bins = 0
self.last_fft_vals = None
self.y_min_fft = None
self.y_max_fft = None
return True
def ensure_init(self, sweep_width: int):
"""Инициализировать буферы при первом свипе. Повторные вызовы — no-op (кроме x_shared)."""
if self.ring is None:
self.width = WF_WIDTH
self.ring = np.full((self.max_sweeps, self.width), np.nan, dtype=np.float32)
self.ring_time = np.full((self.max_sweeps,), np.nan, dtype=np.float64)
self.ring_fft = np.full((self.max_sweeps, self.fft_bins), np.nan, dtype=np.float32)
# Временная ось IFFT: шаг dt = 1/(FREQ_SPAN_GHZ*1e9), переведём в нс
self.fft_time_axis = (
np.arange(IFFT_LEN, dtype=np.float64) / (FREQ_SPAN_GHZ * 1e9) * 1e9
).astype(np.float32)
self.head = 0
# Обновляем x_shared если пришёл свип большего размера
if self.x_shared is None or sweep_width > self.x_shared.size:
self.x_shared = np.linspace(3.323, 14.323, sweep_width, dtype=np.float32)
self.x_shared = np.linspace(FREQ_MIN_GHZ, FREQ_MAX_GHZ, sweep_width, dtype=np.float32)
def push(self, s: np.ndarray):
"""Добавить строку свипа в кольцевой буфер, вычислить FFT-строку."""
@ -75,30 +96,47 @@ class RingBuffer:
self._push_fft(s)
def _push_fft(self, s: np.ndarray):
bins = self.ring_fft.shape[1] # = IFFT_LEN = 1953
if s is None or s.size == 0:
fft_row = np.full((bins,), np.nan, dtype=np.float32)
else:
# 1. Взять первые SWEEP_LEN отсчётов (остаток — нули если свип короче)
sig = np.zeros(SWEEP_LEN, dtype=np.float32)
take = min(int(s.size), SWEEP_LEN)
seg = np.nan_to_num(s[:take], nan=0.0).astype(np.float32, copy=False)
sig[:take] = seg
depth_axis_m, fft_row = compute_ifft_profile_from_sweep(
s,
complex_mode=self.fft_complex_mode,
)
fft_row = np.asarray(fft_row, dtype=np.float32).ravel()
depth_axis_m = np.asarray(depth_axis_m, dtype=np.float32).ravel()
# 2. Собрать двусторонний спектр:
# [ZEROS_LOW нулей | ZEROS_MID нулей | SWEEP_LEN данных]
# = [-14.3..-3.2 ГГц | -3.2..+3.2 ГГц | +3.2..+14.3 ГГц]
data = np.zeros(IFFT_LEN, dtype=np.complex64)
data[ZEROS_LOW + ZEROS_MID:] = sig
n = min(int(fft_row.size), int(depth_axis_m.size))
if n <= 0:
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]
# 3. ifftshift + ifft → временной профиль
spec = np.fft.ifftshift(data)
result = np.fft.ifft(spec)
needs_reset = (
self.ring_fft is None
or self.fft_depth_axis_m is None
or self.fft_bins != n
or self.ring_fft.shape != (self.max_sweeps, n)
or self.fft_depth_axis_m.size != n
)
if (not needs_reset) and n > 0:
prev_axis = self.fft_depth_axis_m
assert prev_axis is not None
if prev_axis.size != n:
needs_reset = True
else:
# Если ось изменилась (например, изменилась длина/частотная сетка), сбрасываем FFT-водопад.
if not np.allclose(prev_axis[[0, -1]], depth_axis_m[[0, -1]], rtol=1e-6, atol=1e-9):
needs_reset = True
# 4. Амплитуда в дБ
mag = np.abs(result).astype(np.float32)
fft_row = (20.0 * np.log10(mag + 1e-9)).astype(np.float32)
if needs_reset:
self.fft_bins = n
self.ring_fft = np.full((self.max_sweeps, n), np.nan, dtype=np.float32)
self.fft_depth_axis_m = depth_axis_m.astype(np.float32, copy=True)
self.y_min_fft = None
self.y_max_fft = None
assert self.ring_fft is not None
prev_head = (self.head - 1) % self.ring_fft.shape[0]
self.ring_fft[prev_head, :] = fft_row
self.last_fft_vals = fft_row

2
run_dataplotter Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/bash
python3 -m rfg_adc_plotter.main --bin --backend mpl $@

View File

@ -0,0 +1,102 @@
from pathlib import Path
import numpy as np
from rfg_adc_plotter.io.capture_reference_loader import (
aggregate_capture_reference,
detect_reference_file_format,
load_capture_sweeps,
)
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():
sweeps = load_capture_sweeps(str(SAMPLE_BG), fancy=False, logscale=False)
assert len(sweeps) > 100
sweep0, info0 = sweeps[0]
assert isinstance(sweep0, np.ndarray)
assert "ch" in info0
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_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))
assert isinstance(vector, np.ndarray)
assert vector.dtype == np.float32
assert summary.sweeps_total == len(sweeps)
assert summary.sweeps_valid > 0
assert summary.sweeps_valid < summary.sweeps_total
assert summary.dominant_width in (759, 758) # sample_data starts at x=1..758 => width=759
def test_preprocessor_can_load_capture_calib_and_background_and_apply():
p = SweepPreprocessor(norm_type="projector", auto_save_live_calib_envelope=False)
p.set_capture_parse_options(fancy=False, logscale=False)
assert p.load_calib_reference(str(SAMPLE_CALIB))
p.set_calib_mode("file")
p.set_calib_enabled(True)
assert p.calib_file_envelope is not None
assert p.calib_external_source_type == "capture"
assert p.load_background_reference(str(SAMPLE_BG))
p.set_background_enabled(True)
assert p.background_source_type == "capture_raw"
n = min(758, int(p.calib_file_envelope.size))
sweep = np.linspace(-100.0, 100.0, n, dtype=np.float32)
res = p.process(sweep, channel=1, update_references=False)
assert res.calibration_applied is True
assert res.background_applied is True
assert res.calibration_source == "capture"
assert "background_capture(raw->calib)" in res.stage_trace
def test_preprocessor_applies_background_for_ch0_reference_too():
p = SweepPreprocessor(norm_type="projector", auto_save_live_calib_envelope=False)
p.set_capture_parse_options(fancy=False, logscale=False)
assert p.load_background_reference(str(SAMPLE_BG))
p.set_background_enabled(True)
n = min(758, int(p.background.size)) if p.background is not None else 758
raw = np.linspace(-10.0, 10.0, n, dtype=np.float32)
res = p.process(raw, channel=0, update_references=True)
assert res.is_calibration_reference is True
assert res.background_applied is True
assert np.any(np.abs(res.processed_sweep - raw) > 0)
assert p.last_calib_sweep is not None
assert np.allclose(p.last_calib_sweep[:n], raw[:n], equal_nan=True)

View File

@ -0,0 +1,54 @@
import numpy as np
from rfg_adc_plotter.processing.fourier import (
compute_ifft_profile_from_sweep,
reconstruct_complex_spectrum_from_real_trace,
)
def test_reconstruct_complex_spectrum_arccos_mode_returns_complex128():
sweep = np.linspace(-3.0, 7.0, 128, dtype=np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="arccos")
assert z.dtype == np.complex128
assert z.shape == sweep.shape
assert np.all(np.isfinite(np.real(z)))
assert np.all(np.isfinite(np.imag(z)))
def test_reconstruct_complex_spectrum_diff_mode_returns_complex128():
sweep = np.linspace(-1.0, 1.0, 128, dtype=np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="diff")
assert z.dtype == np.complex128
assert z.shape == sweep.shape
assert np.all(np.isfinite(np.real(z)))
assert np.all(np.isfinite(np.imag(z)))
def test_reconstruct_complex_spectrum_diff_mode_projects_to_unit_circle():
sweep = np.sin(np.linspace(0.0, 6.0 * np.pi, 256)).astype(np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="diff")
mag = np.abs(z)
assert np.all(np.isfinite(mag))
assert np.allclose(mag, np.ones_like(mag), atol=1e-5, rtol=0.0)
def test_compute_ifft_profile_from_sweep_accepts_both_modes():
sweep = np.linspace(-5.0, 5.0, 257, dtype=np.float32)
d1, y1 = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos")
d2, y2 = compute_ifft_profile_from_sweep(sweep, complex_mode="diff")
assert d1.dtype == np.float32 and y1.dtype == np.float32
assert d2.dtype == np.float32 and y2.dtype == np.float32
assert d1.size == y1.size and d2.size == y2.size
assert d1.size > 0 and d2.size > 0
assert np.all(np.diff(d1) >= 0.0)
assert np.all(np.diff(d2) >= 0.0)
def test_invalid_complex_mode_falls_back_deterministically_in_outer_wrapper():
sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
depth, y = compute_ifft_profile_from_sweep(sweep, complex_mode="unknown")
assert depth.dtype == np.float32
assert y.dtype == np.float32
assert depth.size == y.size
assert depth.size > 0

View File

@ -0,0 +1,75 @@
import numpy as np
from rfg_adc_plotter.processing.fourier import (
build_frequency_axis_hz,
compute_ifft_profile_from_sweep,
normalize_sweep_for_phase,
perform_ifft_depth_response,
reconstruct_complex_spectrum_from_real_trace,
unwrap_arccos_phase_continuous,
)
def test_normalize_sweep_for_phase_max_abs_and_finite():
sweep = np.array([np.nan, -10.0, 5.0, 20.0, -40.0, np.inf, -np.inf], dtype=np.float32)
x = normalize_sweep_for_phase(sweep)
assert x.dtype == np.float64
assert np.all(np.isfinite(x))
assert np.max(np.abs(x)) <= 1.0 + 1e-12
def test_arccos_unwrap_continuous_recovers_complex_phase_without_large_jumps():
phi_true = np.linspace(0.0, 4.0 * np.pi, 1000, dtype=np.float64)
x = np.cos(phi_true)
phi_rec = unwrap_arccos_phase_continuous(x)
assert phi_rec.shape == phi_true.shape
assert np.max(np.abs(np.diff(phi_rec))) < 0.2
z_true = np.exp(1j * phi_true)
z_rec = np.exp(1j * phi_rec)
assert np.allclose(z_rec, z_true, atol=2e-2, rtol=0.0)
def test_reconstruct_complex_spectrum_from_real_trace_output_complex128():
sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep)
assert z.dtype == np.complex128
assert z.shape == sweep.shape
assert np.all(np.isfinite(np.real(z)))
assert np.all(np.isfinite(np.imag(z)))
def test_perform_ifft_depth_response_basic_abs():
n = 128
freqs = build_frequency_axis_hz(n)
s = np.exp(1j * np.linspace(0.0, 2.0 * np.pi, n, dtype=np.float64))
depth_m, y = perform_ifft_depth_response(s, freqs, axis="abs")
assert depth_m.dtype == np.float32
assert y.dtype == np.float32
assert depth_m.ndim == 1 and y.ndim == 1
assert depth_m.size == y.size
assert depth_m.size >= n
assert np.all(np.diff(depth_m) >= 0.0)
assert np.all(y >= 0.0)
def test_perform_ifft_depth_response_bad_grid_returns_fallback_not_exception():
s = np.ones(16, dtype=np.complex128)
freqs_desc = np.linspace(10.0, 1.0, 16, dtype=np.float64)
depth_m, y = perform_ifft_depth_response(s, freqs_desc, axis="abs")
assert depth_m.size == y.size
assert depth_m.size == s.size
assert np.all(np.isfinite(depth_m))
def test_compute_ifft_profile_from_sweep_returns_depth_and_linear_abs():
sweep = np.linspace(-5.0, 7.0, 257, dtype=np.float32)
depth_m, y = compute_ifft_profile_from_sweep(sweep)
assert depth_m.dtype == np.float32
assert y.dtype == np.float32
assert depth_m.size == y.size
assert depth_m.size > 0
assert np.all(np.diff(depth_m) >= 0.0)

View File

@ -0,0 +1,81 @@
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
def test_ring_buffer_allocates_fft_buffers_from_first_push():
ring = RingBuffer(max_sweeps=4)
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
assert ring.fft_depth_axis_m is not None
assert ring.last_fft_vals is not None
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.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
def test_ring_buffer_reallocates_fft_buffers_when_ifft_length_changes():
ring = RingBuffer(max_sweeps=4)
ring.ensure_init(512)
ring.push(np.linspace(-1.0, 1.0, 64, dtype=np.float32))
first_bins = ring.fft_bins
first_shape = None if ring.ring_fft is None else ring.ring_fft.shape
ring.push(np.linspace(-1.0, 1.0, 512, dtype=np.float32))
second_bins = ring.fft_bins
second_shape = None if ring.ring_fft is None else ring.ring_fft.shape
assert ring.ring is not None # raw ring сохраняется
assert first_shape is not None and second_shape is not None
assert first_bins != second_bins
assert second_shape == (ring.max_sweeps, second_bins)
assert ring.fft_depth_axis_m is not None
assert ring.fft_depth_axis_m.size == second_bins
def test_ring_buffer_mode_switch_resets_fft_buffers_only():
ring = RingBuffer(max_sweeps=4)
ring.ensure_init(128)
ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32))
assert ring.ring is not None
assert ring.ring_fft is not None
raw_before = ring.ring.copy()
changed = ring.set_fft_complex_mode("diff")
assert changed is True
assert ring.fft_complex_mode == "diff"
assert ring.ring is not None
assert np.array_equal(ring.ring, raw_before, equal_nan=True)
assert ring.ring_fft is None
assert ring.fft_depth_axis_m is None
assert ring.last_fft_vals is 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
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.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

View File

@ -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