22 Commits

Author SHA1 Message Date
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
8b1d424cbe New tty parser: accepts binary format. Enable arg: --bin 2026-02-17 18:51:12 +03:00
awe
34d151aef1 fix bug 2026-02-13 17:49:43 +03:00
awe
0ecb83751f add background remove 2026-02-13 17:45:14 +03:00
awe
66a318fff8 add calibration file 2026-02-13 17:32:04 +03:00
awe
d2d504f5b8 fix axis 2026-02-11 19:26:00 +03:00
awe
66b9eee230 right ifft implementation 2026-02-11 18:43:43 +03:00
awe
ea57f87920 new graph style 2026-02-11 18:27:12 +03:00
awe
c3acd0c193 new project structure 2026-02-11 16:32:21 +03:00
awe
0eaa07c03a gitignore upd 2026-02-11 16:32:04 +03:00
64c813bf02 implemented new normalisator mode: projector. It takes upper and lower evenlopes of ref signal and projects raw data from evenlopes scope to +-1000 2026-02-10 21:55:12 +03:00
45 changed files with 3842 additions and 3979 deletions

BIN
().npy Normal file

Binary file not shown.

18
.gitignore vendored
View File

@ -1,8 +1,12 @@
.venv/
env/
__pycache__/
*.py[cod]
.pytest_cache/
.Python
my_picocom_logfile.txt
sample_data/
*pyc
__pycache__/
*.log
*.tmp
*.bak
*.swp
*.swo
acm_9
build
.venv
sample_data

185
README.md
View File

@ -1,185 +0,0 @@
# RFG STM32 ADC Receiver GUI
PyQtGraph-приложение для чтения свипов из последовательного порта и отображения:
- текущего свипа
- водопада по свипам
- FFT текущего свипа
- B-scan по FFT
После рефакторинга проект разделен на пакет `rfg_adc_plotter`. Старый запуск через `RFG_ADC_dataplotter.py` сохранен как совместимый wrapper.
## Структура
- `RFG_ADC_dataplotter.py` — совместимый entrypoint
- `rfg_adc_plotter/cli.py` — CLI-аргументы
- `rfg_adc_plotter/io/` — чтение порта и парсеры протоколов
- `rfg_adc_plotter/processing/` — FFT, нормировка, калибровка, поиск пиков
- `rfg_adc_plotter/state/` — runtime state и кольцевые буферы
- `rfg_adc_plotter/gui/pyqtgraph_backend.py` — GUI на PyQtGraph
- `replay_pty.py` — воспроизведение захвата через виртуальный PTY
## Зависимости
Минимально нужны:
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install numpy pyqtgraph PyQt5
```
Если `pyserial` не установлен, приложение попробует открыть порт через raw TTY.
## Быстрый старт
Запуск через старый entrypoint:
```bash
.venv/bin/python RFG_ADC_dataplotter.py /dev/ttyACM0
```
Запуск напрямую через пакет:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0
```
Показать справку:
```bash
.venv/bin/python RFG_ADC_dataplotter.py --help
```
## Примеры запуска
Обычный запуск с живого порта:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --baud 115200
```
Больше истории в водопаде и ограничение FPS:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --max-sweeps 400 --max-fps 20
```
Фиксированный диапазон по оси Y:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --ylim -1000,1000
```
С включенной нормировкой `simple`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --norm-type simple
```
Режим измерения ширины главного пика FFT:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --calibrate
```
Поиск топ-3 пиков относительно rolling median reference:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --peak_search --peak_ref_window 1.5
```
Вычитание среднего спектра по последним секундам:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --spec-mean-sec 3
```
## Протоколы ввода
ASCII-протокол по умолчанию:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0
```
Legacy binary:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --bin
```
Logscale binary с парой `int32`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --logscale
```
Logscale binary `16-bit x2`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_16_bit_x2
```
Тестовый парсер для экспериментального `16-bit x2` потока:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_test
```
## Локальная проверка через replay_pty
Если есть лог-файл захвата, его можно воспроизвести как виртуальный последовательный порт.
В первом терминале:
```bash
.venv/bin/python replay_pty.py my_picocom_logfile.txt --pty /tmp/ttyVIRT0 --speed 1.0
```
Во втором терминале:
```bash
.venv/bin/python -m rfg_adc_plotter.main /tmp/ttyVIRT0
```
Максимально быстрый replay:
```bash
.venv/bin/python replay_pty.py my_picocom_logfile.txt --pty /tmp/ttyVIRT0 --speed 0
```
## Удаленный захват по SSH
В приложении SSH-источник не встроен. Для удаленной проверки нужно сначала получить поток или лог на локальную машину, а затем либо:
- запускать GUI напрямую на локальном PTY
- сохранять поток в файл и воспроизводить его через `replay_pty.py`
Пример команды для ручной диагностики удаленного устройства:
```bash
ssh 192.148.0.148 'ls -l /dev/ttyACM0'
```
Если на удаленной машине есть доступ к потоку, удобнее сохранять его в файл и уже этот файл гонять локально через `replay_pty.py`.
## Проверка и тесты
Синтаксическая проверка:
```bash
python3 -m compileall RFG_ADC_dataplotter.py replay_pty.py rfg_adc_plotter tests
```
Запуск тестов:
```bash
.venv/bin/python -m unittest discover -s tests -v
```
## Замечания
- Поддерживается только PyQtGraph backend.
- `--backend mpl` оставлен только для совместимости CLI и завершится ошибкой.
- Каталоги `sample_data/` и локальные логи добавлены в `.gitignore` и не считаются частью обязательного tracked-состояния репозитория.

View File

@ -1,8 +0,0 @@
#!/usr/bin/env python3
"""Compatibility wrapper for the modularized ADC plotter."""
from rfg_adc_plotter.main import main
if __name__ == "__main__":
main()

BIN
background.npy Normal file

Binary file not shown.

BIN
calib_envelope.npy Normal file

Binary file not shown.

View File

@ -1,7 +1,16 @@
#!/usr/bin/env python3
"""Replay a capture file through a pseudo-TTY for local GUI verification."""
"""
Эмулятор серийного порта: воспроизводит лог-файл в цикле через PTY.
from __future__ import annotations
Использование:
python3 replay_pty.py my_picocom_logfile.txt
python3 replay_pty.py my_picocom_logfile.txt --pty /tmp/ttyVIRT0
python3 replay_pty.py my_picocom_logfile.txt --speed 2.0 # в 2 раза быстрее реального
python3 replay_pty.py my_picocom_logfile.txt --speed 0 # максимально быстро
Затем в другом терминале:
python -m rfg_adc_plotter.main /tmp/ttyVIRT0
"""
import argparse
import os
@ -9,7 +18,7 @@ import sys
import time
def main() -> None:
def main():
parser = argparse.ArgumentParser(
description="Воспроизводит лог-файл через PTY как виртуальный серийный порт."
)
@ -34,18 +43,20 @@ def main() -> None:
"--baud",
type=int,
default=115200,
help="Скорость (бод) для расчета задержек (по умолчанию 115200)",
help="Скорость (бод) для расчёта задержек (по умолчанию 115200)",
)
args = parser.parse_args()
if not os.path.isfile(args.file):
sys.stderr.write(f"[error] Файл не найден: {args.file}\n")
raise SystemExit(1)
sys.exit(1)
# Открываем PTY-пару: master (мы пишем) / slave (GUI читает)
master_fd, slave_fd = os.openpty()
slave_path = os.ttyname(slave_fd)
os.close(slave_fd)
os.close(slave_fd) # GUI откроет slave сам по симлинку
# Симлинк с удобным именем
try:
os.unlink(args.pty)
except FileNotFoundError:
@ -53,25 +64,26 @@ def main() -> None:
os.symlink(slave_path, args.pty)
print(f"PTY slave : {slave_path}")
print(f"Симлинк : {args.pty} -> {slave_path}")
print(f"Запустите : python3 -m rfg_adc_plotter.main {args.pty}")
print(f"Симлинк : {args.pty} {slave_path}")
print(f"Запустите : python -m rfg_adc_plotter.main {args.pty}")
print("Ctrl+C для остановки.\n")
# Задержка на байт: 10 бит (8N1) / скорость / множитель
if args.speed > 0:
bytes_per_sec = args.baud / 10.0 * args.speed
delay_per_byte = 1.0 / bytes_per_sec
else:
delay_per_byte = 0.0
chunk_size = 4096
_CHUNK = 4096
loop = 0
try:
while True:
loop += 1
print(f"[loop {loop}] {args.file}")
with open(args.file, "rb") as handle:
with open(args.file, "rb") as f:
while True:
chunk = handle.read(chunk_size)
chunk = f.read(_CHUNK)
if not chunk:
break
os.write(master_fd, chunk)

View File

@ -1,3 +0,0 @@
"""RFG ADC plotter package."""
__all__ = []

View File

@ -1,120 +0,0 @@
"""Command-line parser for the ADC plotter."""
from __future__ import annotations
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Читает свипы из виртуального COM-порта и рисует: "
"последний свип и водопад (реалтайм)."
)
)
parser.add_argument(
"port",
help="Путь к порту, например /dev/ttyACM1 или COM3 (COM10+: \\\\.\\COM10)",
)
parser.add_argument("--baud", type=int, default=115200, help="Скорость (по умолчанию 115200)")
parser.add_argument("--max-sweeps", type=int, default=200, help="Количество видимых свипов в водопаде")
parser.add_argument("--max-fps", type=float, default=30.0, help="Лимит частоты отрисовки, кадров/с")
parser.add_argument("--cmap", default="viridis", help="Цветовая карта водопада")
parser.add_argument(
"--spec-clip",
default="2,98",
help=(
"Процентильная обрезка уровней водопада спектров, %% (min,max). "
"Напр. 2,98. 'off' — отключить"
),
)
parser.add_argument(
"--spec-mean-sec",
type=float,
default=0.0,
help=(
"Вычитание среднего по каждой частоте за последние N секунд "
"в водопаде спектров (0 — отключить)"
),
)
parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна")
parser.add_argument(
"--fancy",
action="store_true",
help="Заполнять выпавшие точки средними значениями между соседними",
)
parser.add_argument(
"--ylim",
type=str,
default=None,
help="Фиксированные Y-пределы для кривой формата min,max (например -1000,1000). По умолчанию авто",
)
parser.add_argument(
"--backend",
choices=["auto", "pg", "mpl"],
default="pg",
help="Совместимый флаг. Поддерживаются только auto и pg; mpl удален.",
)
parser.add_argument(
"--norm-type",
choices=["projector", "simple"],
default="projector",
help="Тип нормировки: projector (по огибающим в [-1,+1]) или simple (raw/calib)",
)
parser.add_argument(
"--bin",
dest="bin_mode",
action="store_true",
help=(
"Бинарный протокол: старт свипа 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; "
"точки step,uint32(hi16,lo16),0x000A"
),
)
parser.add_argument(
"--logscale",
action="store_true",
default=True,
help=(
"Новый бинарный протокол: точка несет пару int32 (avg_1, avg_2), "
"а свип считается как |10**(avg_1*0.001) - 10**(avg_2*0.001)|"
),
)
parser.add_argument(
"--parser_16_bit_x2",
action="store_true",
help=(
"Бинарный logscale-протокол c парой int16 (avg_1, avg_2): "
"старт 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; точка step,avg1_lo16,avg2_lo16,0xFFFF"
),
)
parser.add_argument(
"--parser_test",
action="store_true",
help=(
"Тестовый парсер для формата 16-bit x2: "
"одиночный 0xFFFF завершает точку, серия 0xFFFF начинает новый свип"
),
)
parser.add_argument(
"--calibrate",
action="store_true",
help=(
"Режим измерения ширины главного пика FFT: рисует красные маркеры "
"границ и фона и выводит ширину пика в статус"
),
)
parser.add_argument(
"--peak_search",
action="store_true",
help=(
"Поиск топ-3 пиков на FFT относительно референса (скользящая медиана) "
"с отрисовкой bounding box и параметров пиков"
),
)
parser.add_argument(
"--peak_ref_window",
type=float,
default=1.0,
help="Ширина окна скользящей медианы для --peak_search, ГГц/м по оси FFT (по умолчанию 1.0)",
)
return parser

View File

@ -1,17 +1,21 @@
"""Shared constants for sweep parsing and visualization."""
WF_WIDTH = 1000
FFT_LEN = 1024
BACKGROUND_MEDIAN_SWEEPS = 64
SWEEP_FREQ_MIN_GHZ = 3.3
SWEEP_FREQ_MAX_GHZ = 14.3
LOG_BASE = 10.0
LOG_SCALER = 0.001
LOG_POSTSCALER = 10.0
LOG_EXP_LIMIT = 300.0
C_M_S = 299_792_458.0
WF_WIDTH = 1000 # максимальное число точек в ряду водопада
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 ГГц
ZEROS_MID = 437 # нули от -3.2 до +3.2 ГГц
SWEEP_LEN = 758 # ожидаемая длина свипа (3.2 → 14.3 ГГц)
FREQ_SPAN_GHZ = 28.6 # полная двусторонняя полоса (-14.3 .. +14.3 ГГц)
IFFT_LEN = ZEROS_LOW + ZEROS_MID + SWEEP_LEN # = 1953

View File

@ -1,5 +0,0 @@
"""GUI backends."""
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
__all__ = ["run_pyqtgraph"]

View File

@ -0,0 +1,680 @@
"""Matplotlib-бэкенд реалтайм-плоттера свипов."""
import sys
import threading
from queue import Queue
from typing import Optional, Tuple
import numpy as np
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 AppState, format_status
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepPacket
def _parse_ylim(ylim_str: Optional[str]) -> Optional[Tuple[float, float]]:
if not ylim_str:
return None
try:
y0, y1 = ylim_str.split(",")
return (float(y0), float(y1))
except Exception:
sys.stderr.write("[warn] Некорректный формат --ylim, игнорирую. Ожидалось min,max\n")
return None
def _parse_spec_clip(spec: Optional[str]) -> Optional[Tuple[float, float]]:
if not spec:
return None
s = str(spec).strip().lower()
if s in ("off", "none", "no"):
return None
try:
p0, p1 = s.replace(";", ",").split(",")
low, high = float(p0), float(p1)
if not (0.0 <= low < high <= 100.0):
return None
return (low, high)
except Exception:
return None
def _visible_levels(data: np.ndarray, axis) -> Optional[Tuple[float, float]]:
"""(vmin, vmax) по текущей видимой области imshow."""
if data.size == 0:
return None
ny, nx = data.shape[0], data.shape[1]
try:
x0, x1 = axis.get_xlim()
y0, y1 = axis.get_ylim()
except Exception:
x0, x1 = 0.0, float(nx - 1)
y0, y1 = 0.0, float(ny - 1)
xmin, xmax = sorted((float(x0), float(x1)))
ymin, ymax = sorted((float(y0), float(y1)))
ix0 = max(0, min(nx - 1, int(np.floor(xmin))))
ix1 = max(0, min(nx - 1, int(np.ceil(xmax))))
iy0 = max(0, min(ny - 1, int(np.floor(ymin))))
iy1 = max(0, min(ny - 1, int(np.ceil(ymax))))
if ix1 < ix0:
ix1 = ix0
if iy1 < iy0:
iy1 = iy0
sub = data[iy0 : iy1 + 1, ix0 : ix1 + 1]
finite = np.isfinite(sub)
if not finite.any():
return None
vals = sub[finite]
vmin = float(np.min(vals))
vmax = float(np.max(vals))
if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin == vmax:
return None
return (vmin, vmax)
def run_matplotlib(args):
try:
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
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)
q: Queue[SweepPacket] = Queue(maxsize=1000)
stop_event = threading.Event()
reader = SweepReader(
args.port,
args.baud,
q,
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()
max_sweeps = int(max(10, args.max_sweeps))
max_fps = max(1.0, float(args.max_fps))
interval_ms = int(1000.0 / max_fps)
spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None))
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.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)
ax_line.set_xlabel("Частота, ГГц")
channel_text = ax_line.text(
0.98, 0.98, "", transform=ax_line.transAxes,
ha="right", va="top", fontsize=9, family="monospace",
)
if fixed_ylim is not None:
ax_line.set_ylim(fixed_ylim)
# График спектра
fft_line_t1, = ax_fft.plot([], [], lw=1, color="tab:blue", label="1/3 (low f)")
fft_line_t2, = ax_fft.plot([], [], lw=1, color="tab:orange", label="2/3 (mid f)")
fft_line_t3, = ax_fft.plot([], [], lw=1, color="tab:green", label="3/3 (high f)")
ax_fft.set_title("FFT", pad=1)
ax_fft.set_xlabel("Глубина, м")
ax_fft.set_ylabel("Амплитуда")
ax_fft.legend(loc="upper right", fontsize=8)
# Водопад сырых данных
img_obj = ax_img.imshow(
np.zeros((1, 1), dtype=np.float32),
aspect="auto", interpolation="nearest", origin="lower", cmap=args.cmap,
)
ax_img.set_title("Сырые данные", pad=12)
ax_img.set_ylabel("частота")
try:
ax_img.tick_params(axis="x", labelbottom=False)
except Exception:
pass
# Водопад спектров
img_fft_obj = ax_spec.imshow(
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("Глубина, м")
try:
ax_spec.tick_params(axis="x", labelbottom=False)
except Exception:
pass
# Слайдеры и чекбокс
contrast_slider = None
try:
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
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:
y0 = int(min(ymin_slider.val, ymax_slider.val))
y1 = int(max(ymin_slider.val, ymax_slider.val))
ax_spec.set_ylim(y0, y1)
fig.canvas.draw_idle()
except Exception:
pass
def _on_calib_file_clicked(_v):
use_file = bool(calib_file_cb.get_status()[0])
if use_file:
ok = _load_calib_from_ui()
if ok:
state.set_calib_mode("file")
else:
calib_file_cb.set_active(0) # снять галочку
else:
state.set_calib_mode("live")
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
_refresh_status_texts()
def _on_calib_clicked(_v):
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
_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])
save_bg_btn = MplButton(ax_btn_bg, "Сохр. фон")
bg_cb = CheckButtons(ax_cb_bg, ["вычет фона"], [False])
def _on_save_bg(_event):
ok = state.save_background()
if ok:
state.load_background()
_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_t1.set_data([], [])
fft_line_t2.set_data([], [])
fft_line_t3.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 = 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 = 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, depth_max))
ax_spec.set_xlim(0, ms - 1)
ax_spec.set_ylim(0.0, depth_max)
ax_fft.set_xlim(0.0, depth_max)
_imshow_initialized = [False]
def update(_frame):
changed = state.drain_queue(q, ring) > 0
if changed and not _imshow_initialized[0] and ring.is_ready:
_init_imshow_extents()
_imshow_initialized[0] = True
# Линейный график свипа
if state.current_sweep_raw is not None:
raw = state.current_sweep_raw
if ring.x_shared is not None and raw.size <= ring.x_shared.size:
xs = ring.x_shared[: raw.size]
else:
xs = np.arange(raw.size, dtype=np.int32)
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 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([], [])
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")
# Профиль по глубине: три линии для 1/3, 2/3, 3/3 частотного диапазона.
third_axes = ring.last_fft_third_axes_m
third_vals = ring.last_fft_third_vals
lines = (fft_line_t1, fft_line_t2, fft_line_t3)
xs_max = []
ys_min = []
ys_max = []
for line_fft, xs_fft, fft_vals in zip(lines, third_axes, third_vals):
if xs_fft is None or fft_vals is None:
line_fft.set_data([], [])
continue
n = min(int(xs_fft.size), int(fft_vals.size))
if n <= 0:
line_fft.set_data([], [])
continue
x_seg = xs_fft[:n]
y_seg = fft_vals[:n]
line_fft.set_data(x_seg, y_seg)
xs_max.append(float(x_seg[n - 1]))
ys_min.append(float(np.nanmin(y_seg)))
ys_max.append(float(np.nanmax(y_seg)))
if xs_max and ys_min and ys_max:
ax_fft.set_xlim(0, float(max(xs_max)))
ax_fft.set_ylim(float(min(ys_min)), float(max(ys_max)))
# Водопад сырых данных
if changed and ring.is_ready:
disp = ring.get_display_ring()
if ring.x_shared is not None:
n = ring.x_shared.size
disp = disp[:n, :]
img_obj.set_data(disp)
img_obj.set_extent((0, ring.max_sweeps - 1, FREQ_MIN, FREQ_MAX))
ax_img.set_ylim(FREQ_MIN, FREQ_MAX)
levels = _visible_levels(disp, ax_img)
if levels is not None:
img_obj.set_clim(vmin=levels[0], vmax=levels[1])
# Водопад спектров
if changed and ring.is_ready:
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:
c = float(contrast_slider.val) / 100.0 if contrast_slider is not None else 1.0
except Exception:
c = 1.0
vmax_eff = levels[0] + c * (levels[1] - levels[0])
img_fft_obj.set_clim(vmin=levels[0], vmax=vmax_eff)
# Статус и подпись канала
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_pre_exp_obj,
line_post_exp_obj,
line_env_lo,
line_env_hi,
img_obj,
fft_line_t1,
fft_line_t2,
fft_line_t3,
img_fft_obj,
status_text,
pipeline_text,
ref_text,
channel_text,
)
ani = FuncAnimation(fig, update, interval=interval_ms, blit=False)
plt.show()
stop_event.set()
reader.join(timeout=1.0)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
"""I/O helpers for serial sources and sweep parsing."""
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
from rfg_adc_plotter.io.sweep_reader import SweepReader
__all__ = ["SerialChunkReader", "SerialLineSource", "SweepReader"]

View File

@ -0,0 +1,227 @@
"""Загрузка эталонов (калибровка/фон) из .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 or (size % 8) != 0:
return None
try:
with open(p, "rb") as f:
sample = f.read(min(size, 8 * 2048))
except Exception:
return None
if len(sample) < 8:
return None
# Быстрый sniff aligned-записей: в валидных записях байт 6 == 0x0A.
recs = len(sample) // 8
if recs <= 0:
return None
marker_hits = 0
start_hits = 0
for i in range(0, recs * 8, 8):
b = sample[i : i + 8]
if b[6] == 0x0A:
marker_hits += 1
if b[:6] == b"\xff\xff\xff\xff\xff\xff":
start_hits += 1
if marker_hits >= max(4, int(recs * 0.8)) and start_hits >= 1:
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

@ -1,6 +1,4 @@
"""Serial input helpers with pyserial and raw TTY fallbacks."""
from __future__ import annotations
"""Источники последовательного ввода: обёртки над pyserial и raw TTY."""
import io
import os
@ -14,13 +12,14 @@ def try_open_pyserial(path: str, baud: int, timeout: float):
except Exception:
return None
try:
return serial.Serial(path, baudrate=baud, timeout=timeout)
ser = serial.Serial(path, baudrate=baud, timeout=timeout)
return ser
except Exception:
return None
class FDReader:
"""Buffered wrapper around a raw TTY file descriptor."""
"""Простой враппер чтения строк из файлового дескриптора TTY."""
def __init__(self, fd: int):
self._fd = fd
@ -34,7 +33,7 @@ class FDReader:
def readline(self) -> bytes:
return self._buf.readline()
def close(self) -> None:
def close(self):
try:
self._buf.close()
except Exception:
@ -42,7 +41,10 @@ class FDReader:
def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
"""Open a TTY without pyserial and configure it via termios."""
"""Открыть TTY без pyserial и настроить порт через termios.
Возвращает FDReader или None при ошибке.
"""
try:
import termios
import tty
@ -67,14 +69,17 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
230400: getattr(termios, "B230400", None),
460800: getattr(termios, "B460800", None),
}
speed = baud_map.get(baud) or termios.B115200
b = baud_map.get(baud) or termios.B115200
attrs[4] = speed
attrs[5] = speed
attrs[4] = b # ispeed
attrs[5] = b # ospeed
# VMIN=1, VTIME=0 — блокирующее чтение по байту
cc = attrs[6]
cc[termios.VMIN] = 1
cc[termios.VTIME] = 0
attrs[6] = cc
termios.tcsetattr(fd, termios.TCSANOW, attrs)
except Exception:
try:
@ -87,11 +92,11 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
class SerialLineSource:
"""Unified line-oriented wrapper for pyserial and raw TTY readers."""
"""Единый интерфейс для чтения строк из порта (pyserial или raw TTY)."""
def __init__(self, path: str, baud: int, timeout: float = 1.0):
self._pyserial = try_open_pyserial(path, baud, timeout)
self._fdreader: Optional[FDReader] = None
self._fdreader = None
self._using = "pyserial" if self._pyserial is not None else "raw"
if self._pyserial is None:
self._fdreader = open_raw_tty(path, baud)
@ -107,12 +112,13 @@ class SerialLineSource:
return self._pyserial.readline()
except Exception:
return b""
try:
return self._fdreader.readline() # type: ignore[union-attr]
except Exception:
return b""
else:
try:
return self._fdreader.readline() # type: ignore[union-attr]
except Exception:
return b""
def close(self) -> None:
def close(self):
try:
if self._pyserial is not None:
self._pyserial.close()
@ -123,7 +129,7 @@ class SerialLineSource:
class SerialChunkReader:
"""Fast non-blocking chunk reader for serial sources."""
"""Быстрое неблокирующее чтение чанков из serial/raw TTY для максимального дренажа буфера."""
def __init__(self, src: SerialLineSource):
self._src = src
@ -145,22 +151,20 @@ class SerialChunkReader:
self._fd = None
def read_available(self) -> bytes:
"""Return currently available bytes or b"" when nothing is ready."""
"""Вернёт доступные байты (b"" если данных нет)."""
if self._ser is not None:
try:
available = int(getattr(self._ser, "in_waiting", 0))
n = int(getattr(self._ser, "in_waiting", 0))
except Exception:
available = 0
if available > 0:
n = 0
if n > 0:
try:
return self._ser.read(available)
return self._ser.read(n)
except Exception:
return b""
return b""
if self._fd is None:
return b""
out = bytearray()
while True:
try:

View File

@ -1,399 +1,210 @@
"""Reusable sweep parsers and sweep assembly helpers."""
"""Переиспользуемые компоненты парсинга бинарных свипов и сборки SweepPacket."""
from __future__ import annotations
import math
import time
from collections import deque
from typing import List, Optional, Sequence, Set
import time
from typing import Iterable, List, Optional, Sequence, Set, Tuple
import numpy as np
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD, LOG_BASE, LOG_EXP_LIMIT, LOG_POSTSCALER, LOG_SCALER
from rfg_adc_plotter.types import ParserEvent, PointEvent, StartEvent, SweepAuxCurves, SweepInfo, SweepPacket
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, int]
def u32_to_i32(value: int) -> int:
return value - 0x1_0000_0000 if (value & 0x8000_0000) else value
def u32_to_i32(v: int) -> int:
"""Преобразование 32-bit слова в знаковое значение."""
return v - 0x1_0000_0000 if (v & 0x8000_0000) else v
def u16_to_i16(value: int) -> int:
return value - 0x1_0000 if (value & 0x8000) else value
def log_value_to_linear(value: int) -> float:
exponent = max(-LOG_EXP_LIMIT, min(LOG_EXP_LIMIT, float(value) * LOG_SCALER))
return float(LOG_BASE ** exponent)
def log_pair_to_sweep(avg_1: int, avg_2: int) -> float:
value_1 = log_value_to_linear(avg_1)
value_2 = log_value_to_linear(avg_2)
return abs(value_1 - value_2) * LOG_POSTSCALER
class AsciiSweepParser:
"""Incremental parser for ASCII sweep streams."""
class BinaryRecordStreamParser:
"""Инкрементальный парсер бинарных записей протокола (по 8 байт)."""
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
def feed(self, data: bytes) -> List[ParserEvent]:
def feed(self, data: bytes) -> List[BinaryEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while True:
nl = self._buf.find(b"\n")
if nl == -1:
break
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
if line.endswith(b"\r"):
line = line[:-1]
if not line:
continue
if line.startswith(b"Sweep_start"):
events.append(StartEvent())
events: List[BinaryEvent] = []
buf = self._buf
while len(buf) >= 8:
w0 = int(buf[0]) | (int(buf[1]) << 8)
w1 = int(buf[2]) | (int(buf[3]) << 8)
w2 = int(buf[4]) | (int(buf[5]) << 8)
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
continue
parts = line.split()
if len(parts) < 3:
if buf[6] == 0x0A:
ch = int(buf[7])
value_u32 = (w1 << 16) | w2
events.append(("point", ch, int(w0), u32_to_i32(value_u32)))
del buf[:8]
self.bytes_consumed += 8
self.point_count += 1
continue
head = parts[0].lower()
try:
if head == b"s":
if len(parts) >= 4:
ch = int(parts[1], 10)
x = int(parts[2], 10)
y = int(parts[3], 10)
else:
ch = 0
x = int(parts[1], 10)
y = int(parts[2], 10)
elif head.startswith(b"s"):
ch = int(head[1:], 10)
x = int(parts[1], 10)
y = int(parts[2], 10)
else:
continue
except Exception:
continue
events.append(PointEvent(ch=int(ch), x=int(x), y=float(y)))
del buf[:1]
self.bytes_consumed += 1
self.desync_count += 1
return events
def buffered_size(self) -> int:
return len(self._buf)
class LegacyBinaryParser:
"""Byte-resynchronizing parser for legacy 8-byte binary records."""
def __init__(self):
self._buf = bytearray()
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while len(self._buf) >= 8:
w0 = self._u16_at(self._buf, 0)
w1 = self._u16_at(self._buf, 2)
w2 = self._u16_at(self._buf, 4)
if w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and self._buf[6] == 0x0A:
events.append(StartEvent(ch=int(self._buf[7])))
del self._buf[:8]
continue
if self._buf[6] == 0x0A:
ch = int(self._buf[7])
value = u32_to_i32((w1 << 16) | w2)
events.append(PointEvent(ch=ch, x=int(w0), y=float(value)))
del self._buf[:8]
continue
del self._buf[:1]
return events
class LogScaleBinaryParser32:
"""Byte-resynchronizing parser for 32-bit logscale pair records."""
def __init__(self):
self._buf = bytearray()
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while len(self._buf) >= 12:
words = [self._u16_at(self._buf, idx * 2) for idx in range(6)]
if words[0:5] == [0xFFFF] * 5 and (words[5] & 0x00FF) == 0x000A:
events.append(StartEvent(ch=int((words[5] >> 8) & 0x00FF)))
del self._buf[:12]
continue
if (words[5] & 0x00FF) == 0x000A and words[0] != 0xFFFF:
ch = int((words[5] >> 8) & 0x00FF)
avg_1 = u32_to_i32((words[1] << 16) | words[2])
avg_2 = u32_to_i32((words[3] << 16) | words[4])
events.append(
PointEvent(
ch=ch,
x=int(words[0]),
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
)
)
del self._buf[:12]
continue
del self._buf[:1]
return events
class LogScale16BitX2BinaryParser:
"""Byte-resynchronizing parser for 16-bit x2 logscale records."""
def __init__(self):
self._buf = bytearray()
self._current_channel = 0
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while len(self._buf) >= 8:
words = [self._u16_at(self._buf, idx * 2) for idx in range(4)]
if words[0:3] == [0xFFFF, 0xFFFF, 0xFFFF] and (words[3] & 0x00FF) == 0x000A:
self._current_channel = int((words[3] >> 8) & 0x00FF)
events.append(StartEvent(ch=self._current_channel))
del self._buf[:8]
continue
if words[3] == 0xFFFF and words[0] != 0xFFFF:
avg_1 = u16_to_i16(words[1])
avg_2 = u16_to_i16(words[2])
events.append(
PointEvent(
ch=self._current_channel,
x=int(words[0]),
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
)
)
del self._buf[:8]
continue
del self._buf[:1]
return events
class ParserTestStreamParser:
"""Parser for the special test 16-bit x2 stream format."""
def __init__(self):
self._buf = bytearray()
self._buf_pos = 0
self._point_buf: list[int] = []
self._ffff_run = 0
self._current_channel = 0
self._expected_step: Optional[int] = None
self._in_sweep = False
self._local_resync = False
def _consume_point(self) -> Optional[PointEvent]:
if len(self._point_buf) != 3:
return None
step = int(self._point_buf[0])
if step <= 0:
return None
if self._expected_step is not None and step < self._expected_step:
return None
avg_1 = u16_to_i16(int(self._point_buf[1]))
avg_2 = u16_to_i16(int(self._point_buf[2]))
self._expected_step = step + 1
return PointEvent(
ch=self._current_channel,
x=step,
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while (self._buf_pos + 1) < len(self._buf):
word = int(self._buf[self._buf_pos]) | (int(self._buf[self._buf_pos + 1]) << 8)
self._buf_pos += 2
if word == 0xFFFF:
self._ffff_run += 1
continue
if self._ffff_run > 0:
bad_point_on_delim = False
if self._in_sweep and self._point_buf and not self._local_resync:
point = self._consume_point()
if point is None:
self._local_resync = True
bad_point_on_delim = True
else:
events.append(point)
self._point_buf.clear()
if self._ffff_run >= 2:
if (word & 0x00FF) == 0x000A:
self._current_channel = (word >> 8) & 0x00FF
self._in_sweep = True
self._expected_step = 1
self._local_resync = False
self._point_buf.clear()
events.append(StartEvent(ch=self._current_channel))
self._ffff_run = 0
continue
if self._in_sweep:
self._local_resync = True
self._ffff_run = 0
continue
if self._local_resync and not bad_point_on_delim:
self._local_resync = False
self._point_buf.clear()
self._ffff_run = 0
if self._in_sweep and not self._local_resync:
self._point_buf.append(word)
if len(self._point_buf) > 3:
self._point_buf.clear()
self._local_resync = True
if self._buf_pos >= 262144:
del self._buf[: self._buf_pos]
self._buf_pos = 0
if (len(self._buf) - self._buf_pos) > 1_000_000:
tail = self._buf[self._buf_pos :]
if len(tail) > 262144:
tail = tail[-262144:]
self._buf = bytearray(tail)
self._buf_pos = 0
return events
def clear_buffer_keep_tail(self, max_tail: int = 262_144):
if len(self._buf) > max_tail:
del self._buf[:-max_tail]
class SweepAssembler:
"""Collect parser events into sweep packets matching runtime expectations."""
"""Собирает точки в свип и применяет ту же постобработку, что realtime parser."""
def __init__(self, fancy: bool = False, apply_inversion: bool = True):
def __init__(self, fancy: bool = False, logscale: bool = False, debug: bool = False):
self._fancy = bool(fancy)
self._apply_inversion = bool(apply_inversion)
self._max_width = 0
self._sweep_idx = 0
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._aux_1: list[float] = []
self._aux_2: list[float] = []
self._ys: list[int] = []
self._cur_channel: Optional[int] = None
self._cur_channels: set[int] = set()
def _reset_current(self) -> None:
def reset_current(self):
self._xs.clear()
self._ys.clear()
self._aux_1.clear()
self._aux_2.clear()
self._cur_channel = None
self._cur_channels.clear()
def _scatter(self, xs: Sequence[int], values: Sequence[float], width: int) -> np.ndarray:
series = np.full((width,), np.nan, dtype=np.float32)
try:
idx = np.asarray(xs, dtype=np.int64)
vals = np.asarray(values, dtype=np.float32)
series[idx] = vals
except Exception:
for x, y in zip(xs, values):
xi = int(x)
if 0 <= xi < width:
series[xi] = float(y)
return series
@staticmethod
def _fill_missing(series: np.ndarray) -> None:
known = ~np.isnan(series)
if not np.any(known):
return
known_idx = np.nonzero(known)[0]
for i0, i1 in zip(known_idx[:-1], known_idx[1:]):
if i1 - i0 > 1:
avg = (series[i0] + series[i1]) * 0.5
series[i0 + 1 : i1] = avg
first_idx = int(known_idx[0])
last_idx = int(known_idx[-1])
if first_idx > 0:
series[:first_idx] = series[first_idx]
if last_idx < series.size - 1:
series[last_idx + 1 :] = series[last_idx]
def consume(self, event: ParserEvent) -> Optional[SweepPacket]:
if isinstance(event, StartEvent):
packet = self.finalize_current()
self._reset_current()
if event.ch is not None:
self._cur_channel = int(event.ch)
self._cur_channels.add(int(event.ch))
return packet
def add_point(self, ch: int, x: int, y: int):
if self._cur_channel is None:
self._cur_channel = int(event.ch)
self._cur_channels.add(int(event.ch))
self._xs.append(int(event.x))
self._ys.append(float(event.y))
if event.aux is not None:
self._aux_1.append(float(event.aux[0]))
self._aux_2.append(float(event.aux[1]))
return None
self._cur_channel = int(ch)
self._cur_channels.add(int(ch))
self._xs.append(int(x))
self._ys.append(int(y))
def finalize_current(self) -> Optional[SweepPacket]:
if not self._xs:
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), int(y))
return out
def finalize_arrays(
self,
xs: Sequence[int],
ys: Sequence[int],
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(self._cur_channels) if self._cur_channels else [0]
ch_list = sorted(channels) if channels else [0]
ch_primary = ch_list[0] if ch_list else 0
width = max(int(max(self._xs)) + 1, 1)
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 = self._scatter(self._xs, self._ys, target_width)
aux_curves: SweepAuxCurves = None
if self._aux_1 and self._aux_2 and len(self._aux_1) == len(self._xs):
aux_curves = (
self._scatter(self._xs, self._aux_1, target_width),
self._scatter(self._xs, self._aux_2, target_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:
self._fill_missing(sweep)
if aux_curves is not None:
self._fill_missing(aux_curves[0])
self._fill_missing(aux_curves[1])
if self._apply_inversion:
try:
mean_value = float(np.nanmean(sweep))
if np.isfinite(mean_value) and mean_value < DATA_INVERSION_THRESHOLD:
sweep *= -1.0
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
now = time.time()
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:
@ -403,7 +214,10 @@ class SweepAssembler:
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()
n_valid = float(sum(value for _ts, value in self._n_valid_hist) / len(self._n_valid_hist))
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))
@ -424,4 +238,10 @@ class SweepAssembler:
"std": std,
"dt_ms": dt_ms,
}
return (sweep, info, aux_curves)
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

@ -1,26 +1,18 @@
"""Background sweep reader thread."""
from __future__ import annotations
"""Фоновый поток чтения и парсинга свипов из последовательного порта."""
import sys
import threading
import time
from queue import Full, Queue
from typing import Optional
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.io.sweep_parser_core import (
AsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
ParserTestStreamParser,
SweepAssembler,
)
from rfg_adc_plotter.types import SweepPacket
class SweepReader(threading.Thread):
"""Read a serial source in the background and emit completed sweep packets."""
"""Фоновый поток: читает строки, формирует завершённые свипы и кладёт в очередь."""
def __init__(
self,
@ -31,69 +23,220 @@ class SweepReader(threading.Thread):
fancy: bool = False,
bin_mode: bool = False,
logscale: bool = False,
parser_16_bit_x2: bool = False,
parser_test: bool = False,
debug: bool = False,
):
super().__init__(daemon=True)
self._port_path = port_path
self._baud = int(baud)
self._queue = out_queue
self._baud = baud
self._q = out_queue
self._stop = stop_event
self._src: Optional[SerialLineSource] = None
self._fancy = bool(fancy)
self._bin_mode = bool(bin_mode)
self._logscale = bool(logscale)
self._parser_16_bit_x2 = bool(parser_16_bit_x2)
self._parser_test = bool(parser_test)
self._src: SerialLineSource | None = None
self._debug = bool(debug)
self._assembler = SweepAssembler(fancy=self._fancy, logscale=self._logscale, debug=self._debug)
def _build_parser(self):
if self._parser_test:
return ParserTestStreamParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._parser_16_bit_x2:
return LogScale16BitX2BinaryParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._logscale:
return LogScaleBinaryParser32(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._bin_mode:
return LegacyBinaryParser(), SweepAssembler(fancy=self._fancy, apply_inversion=True)
return AsciiSweepParser(), SweepAssembler(fancy=self._fancy, apply_inversion=True)
def _enqueue(self, packet: SweepPacket) -> None:
def _finalize_current(self, xs, ys, channels: Optional[set]):
packet = self._assembler.finalize_arrays(xs, ys, channels)
if packet is None:
return
sweep, info = packet
try:
self._queue.put_nowait(packet)
self._q.put_nowait((sweep, info))
except Full:
try:
_ = self._queue.get_nowait()
_ = self._q.get_nowait()
except Exception:
pass
try:
self._queue.put_nowait(packet)
self._q.put_nowait((sweep, info))
except Exception:
pass
def run(self) -> None:
def _run_ascii_stream(self, chunk_reader: SerialChunkReader):
xs: list[int] = []
ys: list[int] = []
cur_channel: Optional[int] = None
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:
buf += data
else:
time.sleep(0.0005)
continue
while True:
nl = buf.find(b"\n")
if nl == -1:
break
line = bytes(buf[:nl])
del buf[: nl + 1]
if line.endswith(b"\r"):
line = line[:-1]
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()
cur_channel = None
cur_channels.clear()
continue
if len(line) >= 3:
parts = line.split()
if len(parts) >= 3 and (parts[0].lower() == b"s" or parts[0].lower().startswith(b"s")):
try:
if parts[0].lower() == b"s":
if len(parts) >= 4:
ch = int(parts[1], 10)
x = int(parts[2], 10)
y = int(parts[3], 10)
else:
ch = 0
x = int(parts[1], 10)
y = int(parts[2], 10)
else:
ch = int(parts[0][1:], 10)
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]
self._finalize_current(xs, ys, cur_channels)
def _run_binary_stream(self, chunk_reader: SerialChunkReader):
xs: list[int] = []
ys: list[int] = []
cur_channel: Optional[int] = None
cur_channels: set[int] = set()
parser = BinaryRecordStreamParser()
# Бинарный протокол (4 слова LE u16 = 8 байт на запись):
# старт свипа: 0xFFFF, 0xFFFF, 0xFFFF, (ch<<8)|0x0A
# Байты на проводе: ff ff ff ff ff ff 0a [ch]
# ch=0 → последнее слово=0x000A; ch=1 → 0x010A; и т.д.
# точка данных: step_u16, value_hi_u16, value_lo_u16, (ch<<8)|0x0A
# Байты на проводе: [step_lo step_hi] [hi_lo hi_hi] [lo_lo lo_hi] 0a [ch]
# value_i32 = sign_extend((value_hi<<16)|value_lo)
# Признак записи: байт 6 == 0x0A, байт 7 — номер канала.
# При десинхронизации сдвигаемся на 1 БАЙТ (не слово) для самосинхронизации.
_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:
events = parser.feed(data)
else:
time.sleep(0.0005)
continue
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_channels.clear()
cur_channel = ch_new
cur_channels.add(cur_channel)
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(cur_channel))
xs.append(int(step))
ys.append(int(value_i32))
_dbg_point_count += 1
if self._debug and _dbg_point_count <= 3:
sys.stderr.write(
f"[debug] BIN точка: step={int(step)} ch={int(ch_from_term)} → value={int(value_i32)}\n"
)
_dbg_byte_count = parser.bytes_consumed
_dbg_desync_count = parser.desync_count
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"
)
if parser.buffered_size() > 1_000_000:
parser.clear_buffer_keep_tail(262_144)
self._finalize_current(xs, ys, cur_channels)
def run(self):
try:
self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0)
sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n")
except Exception as exc:
sys.stderr.write(f"[error] {exc}\n")
except Exception as e:
sys.stderr.write(f"[error] {e}\n")
return
parser, assembler = self._build_parser()
try:
chunk_reader = SerialChunkReader(self._src)
while not self._stop.is_set():
data = chunk_reader.read_available()
if not data:
time.sleep(0.0005)
continue
for event in parser.feed(data):
packet = assembler.consume(event)
if packet is not None:
self._enqueue(packet)
packet = assembler.finalize_current()
if packet is not None:
self._enqueue(packet)
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:
self._run_ascii_stream(chunk_reader)
finally:
try:
if self._src is not None:

138
rfg_adc_plotter/main.py Normal file → Executable file
View File

@ -1,25 +1,137 @@
"""Main entrypoint for the modularized ADC plotter."""
#!/usr/bin/env python3
"""
Реалтайм-плоттер для свипов из виртуального COM-порта.
from __future__ import annotations
Формат строк:
- "Sweep_start" — начало нового свипа (предыдущий считается завершённым)
- "s CH X Y" — точка (номер канала, индекс X, значение Y), все целые со знаком
Отрисовываются четыре графика:
- Сырые данные: последний полученный свип (Y vs X)
- Водопад сырых данных: последние N свипов
- FFT текущего свипа
- B-scan: водопад FFT-строк
Зависимости: numpy. PySerial опционален — при его отсутствии
используется сырой доступ к TTY через termios.
GUI: matplotlib (совместимый) или pyqtgraph (быстрый).
"""
import argparse
import sys
from rfg_adc_plotter.cli import build_parser
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Читает свипы из виртуального COM-порта и рисует: "
"последний свип и водопад (реалтайм)."
)
)
parser.add_argument(
"port",
help="Путь к порту, например /dev/ttyACM1 или COM3 (COM10+: \\\\.\\COM10)",
)
parser.add_argument("--baud", type=int, default=115200, help="Скорость (по умолчанию 115200)")
parser.add_argument("--max-sweeps", type=int, default=200, help="Количество видимых свипов в водопаде")
parser.add_argument("--max-fps", type=float, default=30.0, help="Лимит частоты отрисовки, кадров/с")
parser.add_argument("--cmap", default="viridis", help="Цветовая карта водопада")
parser.add_argument(
"--spec-clip",
default="2,98",
help=(
"Процентильная обрезка уровней водопада спектров, %% (min,max). "
"Напр. 2,98. 'off' — отключить"
),
)
parser.add_argument(
"--spec-mean-sec",
type=float,
default=0.0,
help=(
"Вычитание среднего по каждой частоте за последние N секунд "
"в водопаде спектров (0 — отключить)"
),
)
parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна")
parser.add_argument(
"--fancy",
action="store_true",
help="Заполнять выпавшие точки средними значениями между соседними",
)
parser.add_argument(
"--ylim",
type=str,
default=None,
help="Фиксированные Y-пределы для кривой формата min,max (например -1000,1000). По умолчанию авто",
)
parser.add_argument(
"--backend",
choices=["auto", "pg", "mpl"],
default="auto",
help="Графический бэкенд: pyqtgraph (pg) — быстрее; matplotlib (mpl) — совместимый. По умолчанию auto",
)
parser.add_argument(
"--norm-type",
choices=["projector", "simple"],
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=(
"Бинарный протокол (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
def main() -> None:
def main():
args = build_parser().parse_args()
if args.backend == "mpl":
sys.stderr.write("[error] Matplotlib backend removed. Use --backend pg or --backend auto.\n")
raise SystemExit(2)
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
if args.backend == "pg":
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
try:
run_pyqtgraph(args)
except Exception as e:
sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {e}\n")
sys.exit(1)
return
try:
run_pyqtgraph(args)
except Exception as exc:
sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {exc}\n")
raise SystemExit(1) from exc
if args.backend == "auto":
try:
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
run_pyqtgraph(args)
return
except Exception:
pass # Откатываемся на matplotlib
from rfg_adc_plotter.gui.matplotlib_backend import run_matplotlib
run_matplotlib(args)
if __name__ == "__main__":

View File

@ -1,67 +0,0 @@
"""Pure sweep-processing helpers."""
from rfg_adc_plotter.processing.background import (
load_fft_background,
save_fft_background,
subtract_fft_background,
validate_fft_background,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
calibrate_freqs,
get_calibration_base,
get_calibration_coeffs,
load_calib_envelope,
recalculate_calibration_c,
save_calib_envelope,
set_calibration_base_value,
)
from rfg_adc_plotter.processing.fft import (
compute_distance_axis,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
)
from rfg_adc_plotter.processing.formatting import (
compute_auto_ylim,
format_status_kv,
parse_spec_clip,
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
normalize_by_envelope,
normalize_by_calib,
)
from rfg_adc_plotter.processing.peaks import (
find_peak_width_markers,
find_top_peaks_over_ref,
rolling_median_ref,
)
__all__ = [
"build_calib_envelopes",
"build_calib_envelope",
"calibrate_freqs",
"compute_auto_ylim",
"compute_distance_axis",
"compute_fft_mag_row",
"compute_fft_row",
"fft_mag_to_db",
"find_peak_width_markers",
"find_top_peaks_over_ref",
"format_status_kv",
"get_calibration_base",
"get_calibration_coeffs",
"load_calib_envelope",
"load_fft_background",
"normalize_by_envelope",
"normalize_by_calib",
"parse_spec_clip",
"recalculate_calibration_c",
"rolling_median_ref",
"save_calib_envelope",
"save_fft_background",
"set_calibration_base_value",
"subtract_fft_background",
"validate_fft_background",
]

View File

@ -1,66 +0,0 @@
"""Helpers for persisted FFT background profiles."""
from __future__ import annotations
from pathlib import Path
import numpy as np
def validate_fft_background(background: np.ndarray) -> np.ndarray:
"""Validate a saved FFT background payload."""
values = np.asarray(background)
if values.ndim != 1:
raise ValueError("FFT background must be a 1D array")
if not np.issubdtype(values.dtype, np.number):
raise ValueError("FFT background must be numeric")
values = np.asarray(values, dtype=np.float32).reshape(-1)
if values.size == 0:
raise ValueError("FFT background is empty")
return values
def _normalize_background_path(path: str | Path) -> Path:
out = Path(path).expanduser()
if out.suffix.lower() != ".npy":
out = out.with_suffix(".npy")
return out
def save_fft_background(path: str | Path, background: np.ndarray) -> str:
"""Persist an FFT background profile as a .npy file."""
normalized_path = _normalize_background_path(path)
values = validate_fft_background(background)
np.save(normalized_path, values.astype(np.float32, copy=False))
return str(normalized_path)
def load_fft_background(path: str | Path) -> np.ndarray:
"""Load and validate an FFT background profile from a .npy file."""
normalized_path = _normalize_background_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_fft_background(loaded)
def subtract_fft_background(signal_mag: np.ndarray, background_mag: np.ndarray) -> np.ndarray:
"""Subtract a background profile from FFT magnitudes in linear amplitude."""
signal = np.asarray(signal_mag, dtype=np.float32)
background = validate_fft_background(background_mag)
if signal.ndim == 1:
if signal.size != background.size:
raise ValueError("FFT background size does not match signal size")
valid = np.isfinite(signal) & np.isfinite(background)
out = np.full_like(signal, np.nan, dtype=np.float32)
if np.any(valid):
out[valid] = np.maximum(signal[valid] - background[valid], 0.0)
return out
if signal.ndim == 2:
if signal.shape[0] != background.size:
raise ValueError("FFT background size does not match signal rows")
background_2d = background[:, None]
valid = np.isfinite(signal) & np.isfinite(background_2d)
diff = signal - background_2d
return np.where(valid, np.maximum(diff, 0.0), np.nan).astype(np.float32, copy=False)
raise ValueError("FFT background subtraction supports only 1D or 2D signals")

View File

@ -1,124 +0,0 @@
"""Frequency-axis calibration helpers."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
import numpy as np
from rfg_adc_plotter.constants import SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.processing.normalization import build_calib_envelopes
from rfg_adc_plotter.types import SweepData
def recalculate_calibration_c(
base_coeffs: np.ndarray,
f_min: float = SWEEP_FREQ_MIN_GHZ,
f_max: float = SWEEP_FREQ_MAX_GHZ,
) -> np.ndarray:
"""Recalculate coefficients while preserving sweep edges."""
coeffs = np.asarray(base_coeffs, dtype=np.float64).reshape(-1)
if coeffs.size < 3:
out = np.zeros((3,), dtype=np.float64)
out[: coeffs.size] = coeffs
coeffs = out
c0, c1, c2 = float(coeffs[0]), float(coeffs[1]), float(coeffs[2])
x0 = float(f_min)
x1 = float(f_max)
y0 = c0 + c1 * x0 + c2 * (x0 ** 2)
y1 = c0 + c1 * x1 + c2 * (x1 ** 2)
if not (np.isfinite(y0) and np.isfinite(y1)) or y1 == y0:
return np.asarray([c0, c1, c2], dtype=np.float64)
scale = (x1 - x0) / (y1 - y0)
shift = x0 - scale * y0
return np.asarray(
[
shift + scale * c0,
scale * c1,
scale * c2,
],
dtype=np.float64,
)
CALIBRATION_C_BASE = np.asarray([0.0, 1.0, 0.025], dtype=np.float64)
CALIBRATION_C = recalculate_calibration_c(CALIBRATION_C_BASE)
def get_calibration_base() -> np.ndarray:
return np.asarray(CALIBRATION_C_BASE, dtype=np.float64).copy()
def get_calibration_coeffs() -> np.ndarray:
return np.asarray(CALIBRATION_C, dtype=np.float64).copy()
def set_calibration_base_value(index: int, value: float) -> np.ndarray:
"""Update one base coefficient and recalculate the working coefficients."""
global CALIBRATION_C
CALIBRATION_C_BASE[int(index)] = float(value)
CALIBRATION_C = recalculate_calibration_c(CALIBRATION_C_BASE)
return get_calibration_coeffs()
def calibrate_freqs(sweep: Mapping[str, Any]) -> SweepData:
"""Return a sweep copy with calibrated and resampled frequency axis."""
freqs = np.asarray(sweep["F"], dtype=np.float64).copy()
values = np.asarray(sweep["I"], dtype=np.float64).copy()
coeffs = np.asarray(CALIBRATION_C, dtype=np.float64)
if freqs.size > 0:
freqs = coeffs[0] + coeffs[1] * freqs + coeffs[2] * (freqs * freqs)
if freqs.size >= 2:
freqs_cal = np.linspace(float(freqs[0]), float(freqs[-1]), freqs.size, dtype=np.float64)
values_cal = np.interp(freqs_cal, freqs, values).astype(np.float64)
else:
freqs_cal = freqs.copy()
values_cal = values.copy()
return {
"F": freqs_cal,
"I": values_cal,
}
def build_calib_envelope(sweep: np.ndarray) -> np.ndarray:
"""Build the active calibration envelope from a raw sweep."""
values = np.asarray(sweep, dtype=np.float32).reshape(-1)
if values.size == 0:
raise ValueError("Calibration sweep is empty")
_, upper = build_calib_envelopes(values)
return np.asarray(upper, dtype=np.float32)
def validate_calib_envelope(envelope: np.ndarray) -> np.ndarray:
"""Validate a saved calibration envelope payload."""
values = np.asarray(envelope, dtype=np.float32).reshape(-1)
if values.size == 0:
raise ValueError("Calibration envelope is empty")
if not np.issubdtype(values.dtype, np.number):
raise ValueError("Calibration envelope must be numeric")
return values
def _normalize_calib_path(path: str | Path) -> Path:
out = Path(path).expanduser()
if out.suffix.lower() != ".npy":
out = out.with_suffix(".npy")
return out
def save_calib_envelope(path: str | Path, envelope: np.ndarray) -> str:
"""Persist a calibration envelope as a .npy file and return the final path."""
normalized_path = _normalize_calib_path(path)
values = validate_calib_envelope(envelope)
np.save(normalized_path, values.astype(np.float32, copy=False))
return str(normalized_path)
def load_calib_envelope(path: str | Path) -> np.ndarray:
"""Load and validate a calibration envelope from a .npy file."""
normalized_path = _normalize_calib_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_calib_envelope(loaded)

View File

@ -1,267 +0,0 @@
"""FFT helpers for line and waterfall views."""
from __future__ import annotations
from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import C_M_S, FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
def _finite_freq_bounds(freqs: Optional[np.ndarray]) -> Optional[Tuple[float, float]]:
"""Return finite frequency bounds for the current working segment."""
if freqs is None:
return None
freq_arr = np.asarray(freqs, dtype=np.float64).reshape(-1)
finite = freq_arr[np.isfinite(freq_arr)]
if finite.size < 2:
return None
f_min = float(np.min(finite))
f_max = float(np.max(finite))
if not np.isfinite(f_min) or not np.isfinite(f_max) or f_max <= f_min:
return None
return f_min, f_max
def prepare_fft_segment(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
fft_len: int = FFT_LEN,
) -> Optional[Tuple[np.ndarray, int]]:
"""Prepare a sweep segment for FFT on a uniform frequency grid."""
take_fft = min(int(sweep.size), int(fft_len))
if take_fft <= 0:
return None
sweep_seg = np.asarray(sweep[:take_fft], dtype=np.float32)
fallback = np.nan_to_num(sweep_seg, nan=0.0).astype(np.float32, copy=False)
if freqs is None:
return fallback, take_fft
freq_arr = np.asarray(freqs)
if freq_arr.size < take_fft:
return fallback, take_fft
freq_seg = np.asarray(freq_arr[:take_fft], dtype=np.float64)
valid = np.isfinite(sweep_seg) & np.isfinite(freq_seg)
if int(np.count_nonzero(valid)) < 2:
return fallback, take_fft
x_valid = freq_seg[valid]
y_valid = sweep_seg[valid]
order = np.argsort(x_valid, kind="mergesort")
x_valid = x_valid[order]
y_valid = y_valid[order]
x_unique, unique_idx = np.unique(x_valid, return_index=True)
y_unique = y_valid[unique_idx]
if x_unique.size < 2 or x_unique[-1] <= x_unique[0]:
return fallback, take_fft
x_uniform = np.linspace(float(x_unique[0]), float(x_unique[-1]), take_fft, dtype=np.float64)
resampled = np.interp(x_uniform, x_unique, y_unique).astype(np.float32)
return resampled, take_fft
def build_symmetric_ifft_spectrum(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
fft_len: int = FFT_LEN,
) -> Optional[np.ndarray]:
"""Build a centered symmetric spectrum over [-f_max, f_max] for IFFT."""
if fft_len <= 0:
return None
bounds = _finite_freq_bounds(freqs)
if bounds is None:
f_min = float(SWEEP_FREQ_MIN_GHZ)
f_max = float(SWEEP_FREQ_MAX_GHZ)
else:
f_min, f_max = bounds
freq_axis = np.linspace(-f_max, f_max, int(fft_len), dtype=np.float64)
neg_idx_all = np.flatnonzero(freq_axis <= (-f_min))
pos_idx_all = np.flatnonzero(freq_axis >= f_min)
band_len = int(min(neg_idx_all.size, pos_idx_all.size))
if band_len <= 1:
return None
neg_idx = neg_idx_all[:band_len]
pos_idx = pos_idx_all[-band_len:]
prepared = prepare_fft_segment(sweep, freqs, fft_len=band_len)
if prepared is None:
return None
fft_seg, take_fft = prepared
if take_fft != band_len:
fft_seg = np.asarray(fft_seg[:band_len], dtype=np.float32)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=np.float32)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band = np.nan_to_num(fft_seg, nan=0.0).astype(np.float32, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=np.float32)
spectrum[pos_idx] = band
spectrum[neg_idx] = band[::-1]
return spectrum
def build_positive_only_centered_ifft_spectrum(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
fft_len: int = FFT_LEN,
) -> Optional[np.ndarray]:
"""Build a centered spectrum with zeros from -f_max to +f_min."""
if fft_len <= 0:
return None
bounds = _finite_freq_bounds(freqs)
if bounds is None:
f_min = float(SWEEP_FREQ_MIN_GHZ)
f_max = float(SWEEP_FREQ_MAX_GHZ)
else:
f_min, f_max = bounds
freq_axis = np.linspace(-f_max, f_max, int(fft_len), dtype=np.float64)
pos_idx = np.flatnonzero(freq_axis >= f_min)
band_len = int(pos_idx.size)
if band_len <= 1:
return None
prepared = prepare_fft_segment(sweep, freqs, fft_len=band_len)
if prepared is None:
return None
fft_seg, take_fft = prepared
if take_fft != band_len:
fft_seg = np.asarray(fft_seg[:band_len], dtype=np.float32)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=np.float32)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band = np.nan_to_num(fft_seg, nan=0.0).astype(np.float32, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=np.float32)
spectrum[pos_idx] = band
return spectrum
def fft_mag_to_db(mag: np.ndarray) -> np.ndarray:
"""Convert magnitude to dB with safe zero handling."""
mag_arr = np.asarray(mag, dtype=np.float32)
safe_mag = np.maximum(mag_arr, 0.0)
return (20.0 * np.log10(safe_mag + 1e-9)).astype(np.float32, copy=False)
def _compute_fft_mag_row_direct(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
) -> np.ndarray:
prepared = prepare_fft_segment(sweep, freqs, fft_len=FFT_LEN)
if prepared is None:
return np.full((bins,), np.nan, dtype=np.float32)
fft_seg, take_fft = prepared
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
window = np.hanning(take_fft).astype(np.float32)
fft_in[:take_fft] = fft_seg * window
spec = np.fft.ifft(fft_in)
mag = np.abs(spec).astype(np.float32)
if mag.shape[0] != bins:
mag = mag[:bins]
return mag
def _normalize_fft_mode(mode: str | None, symmetric: Optional[bool]) -> str:
if symmetric is not None:
return "symmetric" if symmetric else "direct"
normalized = str(mode or "symmetric").strip().lower()
if normalized in {"direct", "ordinary", "normal"}:
return "direct"
if normalized in {"symmetric", "sym", "mirror"}:
return "symmetric"
if normalized in {"positive_only", "positive-centered", "positive_centered", "zero_left"}:
return "positive_only"
raise ValueError(f"Unsupported FFT mode: {mode!r}")
def compute_fft_mag_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a linear FFT magnitude row."""
if bins <= 0:
return np.zeros((0,), dtype=np.float32)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "direct":
return _compute_fft_mag_row_direct(sweep, freqs, bins)
if fft_mode == "positive_only":
spectrum_centered = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
else:
spectrum_centered = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
if spectrum_centered is None:
return np.full((bins,), np.nan, dtype=np.float32)
spec = np.fft.ifft(np.fft.ifftshift(spectrum_centered))
mag = np.abs(spec).astype(np.float32)
if mag.shape[0] != bins:
mag = mag[:bins]
return mag
def compute_fft_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a dB FFT row."""
return fft_mag_to_db(compute_fft_mag_row(sweep, freqs, bins, mode=mode, symmetric=symmetric))
def compute_distance_axis(
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute the one-way distance axis for IFFT output."""
if bins <= 0:
return np.zeros((0,), dtype=np.float64)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode in {"symmetric", "positive_only"}:
bounds = _finite_freq_bounds(freqs)
if bounds is None:
f_max = float(SWEEP_FREQ_MAX_GHZ)
else:
_, f_max = bounds
df_ghz = (2.0 * f_max) / max(1, FFT_LEN - 1)
else:
if freqs is None:
return np.arange(bins, dtype=np.float64)
freq_arr = np.asarray(freqs, dtype=np.float64)
finite = freq_arr[np.isfinite(freq_arr)]
if finite.size < 2:
return np.arange(bins, dtype=np.float64)
df_ghz = float((finite[-1] - finite[0]) / max(1, finite.size - 1))
df_hz = abs(df_ghz) * 1e9
if not np.isfinite(df_hz) or df_hz <= 0.0:
return np.arange(bins, dtype=np.float64)
step_m = C_M_S / (2.0 * FFT_LEN * df_hz)
return np.arange(bins, dtype=np.float64) * step_m

View File

@ -1,71 +0,0 @@
"""Formatting and display-range helpers."""
from __future__ import annotations
from typing import Any, Mapping, Optional, Tuple
import numpy as np
def format_status_kv(data: Mapping[str, Any]) -> str:
"""Convert status metrics into a compact single-line representation."""
def _fmt(value: Any) -> str:
if value is None:
return "NA"
try:
f_value = float(value)
except Exception:
return str(value)
if not np.isfinite(f_value):
return "nan"
if abs(f_value) >= 1000 or (0 < abs(f_value) < 0.01):
return f"{f_value:.3g}"
return f"{f_value:.3f}".rstrip("0").rstrip(".")
return " ".join(f"{key}:{_fmt(value)}" for key, value in data.items())
def parse_spec_clip(spec: Optional[str]) -> Optional[Tuple[float, float]]:
"""Parse a waterfall percentile clip specification."""
if not spec:
return None
value = str(spec).strip().lower()
if value in ("off", "none", "no"):
return None
try:
p0, p1 = value.replace(";", ",").split(",")
low = float(p0)
high = float(p1)
if not (0.0 <= low < high <= 100.0):
return None
return (low, high)
except Exception:
return None
def compute_auto_ylim(*series_list: Optional[np.ndarray]) -> Optional[Tuple[float, float]]:
"""Compute a common Y-range with a small padding."""
y_min: Optional[float] = None
y_max: Optional[float] = None
for series in series_list:
if series is None:
continue
arr = np.asarray(series)
if arr.size == 0:
continue
finite = arr[np.isfinite(arr)]
if finite.size == 0:
continue
cur_min = float(np.min(finite))
cur_max = float(np.max(finite))
y_min = cur_min if y_min is None else min(y_min, cur_min)
y_max = cur_max if y_max is None else max(y_max, cur_max)
if y_min is None or y_max is None:
return None
if y_min == y_max:
pad = max(1.0, abs(y_min) * 0.05)
else:
pad = 0.05 * (y_max - y_min)
return (y_min - pad, y_max + pad)

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

@ -1,173 +0,0 @@
"""Sweep normalization helpers."""
from __future__ import annotations
from typing import Tuple
import numpy as np
def normalize_sweep_simple(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Simple element-wise raw/calib normalization."""
width = min(raw.size, calib.size)
if width <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
with np.errstate(divide="ignore", invalid="ignore"):
out[:width] = raw[:width] / calib[:width]
out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)
return out
def build_calib_envelopes(calib: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""Estimate smooth lower/upper envelopes from local extrema."""
n = int(calib.size)
if n <= 0:
empty = np.zeros((0,), dtype=np.float32)
return empty, empty
values = np.asarray(calib, dtype=np.float32)
finite = np.isfinite(values)
if not np.any(finite):
zeros = np.zeros_like(values, dtype=np.float32)
return zeros, zeros
if not np.all(finite):
x = np.arange(n, dtype=np.float32)
values = values.copy()
values[~finite] = np.interp(x[~finite], x[finite], values[finite]).astype(np.float32)
if n < 3:
return values.copy(), values.copy()
x = np.arange(n, dtype=np.float32)
def _moving_average(series: np.ndarray, window: int) -> np.ndarray:
width = max(1, int(window))
if width <= 1 or series.size <= 2:
return np.asarray(series, dtype=np.float32).copy()
if width % 2 == 0:
width += 1
pad = width // 2
padded = np.pad(np.asarray(series, dtype=np.float32), (pad, pad), mode="edge")
kernel = np.full((width,), 1.0 / float(width), dtype=np.float32)
return np.convolve(padded, kernel, mode="valid").astype(np.float32)
def _smooth_extrema_envelope(use_max: bool) -> np.ndarray:
step = max(3, n // 32)
node_idx_list = []
for start in range(0, n, step):
stop = min(n, start + step)
segment = values[start:stop]
idx_rel = int(np.argmax(segment) if use_max else np.argmin(segment))
node_idx_list.append(start + idx_rel)
extrema_idx = np.unique(np.asarray(node_idx_list, dtype=np.int64))
if extrema_idx.size == 0:
extrema_idx = np.asarray([int(np.argmax(values) if use_max else np.argmin(values))], dtype=np.int64)
node_idx = np.unique(np.concatenate(([0], extrema_idx, [n - 1]))).astype(np.int64)
node_vals = values[node_idx].astype(np.float32, copy=True)
node_vals[0] = float(values[extrema_idx[0]])
node_vals[-1] = float(values[extrema_idx[-1]])
node_vals = _moving_average(node_vals, 3)
node_vals[0] = float(values[extrema_idx[0]])
node_vals[-1] = float(values[extrema_idx[-1]])
envelope = np.interp(x, node_idx.astype(np.float32), node_vals).astype(np.float32)
smooth_window = max(1, n // 64)
if smooth_window > 1:
envelope = _moving_average(envelope, smooth_window)
return envelope
upper = _smooth_extrema_envelope(use_max=True)
lower = _smooth_extrema_envelope(use_max=False)
swap = lower > upper
if np.any(swap):
tmp = upper[swap].copy()
upper[swap] = lower[swap]
lower[swap] = tmp
return lower, upper
def normalize_sweep_projector(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Project raw values between calibration envelopes into [-1000, 1000]."""
width = min(raw.size, calib.size)
if width <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
raw_seg = np.asarray(raw[:width], dtype=np.float32)
lower, upper = build_calib_envelopes(np.asarray(calib[:width], dtype=np.float32))
span = upper - lower
finite_span = span[np.isfinite(span) & (span > 0)]
if finite_span.size > 0:
eps = max(float(np.median(finite_span)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = (
np.isfinite(raw_seg)
& np.isfinite(lower)
& np.isfinite(upper)
& (span > eps)
)
if np.any(valid):
proj = np.empty_like(raw_seg, dtype=np.float32)
proj[valid] = ((2.0 * (raw_seg[valid] - lower[valid]) / span[valid]) - 1.0) * 1000.0
proj[valid] = np.clip(proj[valid], -1000.0, 1000.0)
proj[~valid] = np.nan
out[:width] = proj
return out
def resample_envelope(envelope: np.ndarray, width: int) -> np.ndarray:
"""Resample an envelope to the target sweep width on the index axis."""
target_width = int(width)
if target_width <= 0:
return np.zeros((0,), dtype=np.float32)
values = np.asarray(envelope, dtype=np.float32).reshape(-1)
if values.size == 0:
return np.full((target_width,), np.nan, dtype=np.float32)
if values.size == target_width:
return values.astype(np.float32, copy=True)
x_src = np.arange(values.size, dtype=np.float32)
finite = np.isfinite(values)
if not np.any(finite):
return np.full((target_width,), np.nan, dtype=np.float32)
if int(np.count_nonzero(finite)) == 1:
fill = float(values[finite][0])
return np.full((target_width,), fill, dtype=np.float32)
x_dst = np.linspace(0.0, float(values.size - 1), target_width, dtype=np.float32)
return np.interp(x_dst, x_src[finite], values[finite]).astype(np.float32)
def normalize_by_envelope(raw: np.ndarray, envelope: np.ndarray) -> np.ndarray:
"""Normalize a sweep by an envelope with safe resampling and zero protection."""
raw_arr = np.asarray(raw, dtype=np.float32).reshape(-1)
if raw_arr.size == 0:
return raw_arr.copy()
env = resample_envelope(envelope, raw_arr.size)
out = np.full_like(raw_arr, np.nan, dtype=np.float32)
den_eps = np.float32(1e-9)
valid = np.isfinite(raw_arr) & np.isfinite(env)
if np.any(valid):
with np.errstate(divide="ignore", invalid="ignore"):
denom = env[valid] + np.where(env[valid] >= 0.0, den_eps, -den_eps)
out[valid] = raw_arr[valid] / denom
return np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)
def normalize_by_calib(raw: np.ndarray, calib: np.ndarray, norm_type: str) -> np.ndarray:
"""Apply the selected normalization method."""
norm = str(norm_type).strip().lower()
if norm == "simple":
return normalize_sweep_simple(raw, calib)
return normalize_sweep_projector(raw, calib)

View File

@ -0,0 +1,149 @@
"""Алгоритмы нормировки свипов по калибровочной кривой."""
from typing import Tuple
import numpy as np
def normalize_simple(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Простая нормировка: поэлементное деление raw/calib."""
w = min(raw.size, calib.size)
if w <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
with np.errstate(divide="ignore", invalid="ignore"):
out[:w] = raw[:w] / calib[:w]
out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)
return out
def build_calib_envelopes(calib: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""Оценить огибающую по модулю сигнала.
Возвращает (lower, upper) = (-envelope, +envelope), где envelope —
интерполяция через локальные максимумы |calib|.
"""
n = int(calib.size)
if n <= 0:
empty = np.zeros((0,), dtype=np.float32)
return empty, empty
y = np.asarray(calib, dtype=np.float32)
finite = np.isfinite(y)
if not np.any(finite):
zeros = np.zeros_like(y, dtype=np.float32)
return zeros, zeros
if not np.all(finite):
x = np.arange(n, dtype=np.float32)
y = y.copy()
y[~finite] = np.interp(x[~finite], x[finite], y[finite]).astype(np.float32)
a = np.abs(y)
if n < 3:
env = a.copy()
return -env, env
da = np.diff(a)
s = np.sign(da).astype(np.int8, copy=False)
if np.any(s == 0):
for i in range(1, s.size):
if s[i] == 0:
s[i] = s[i - 1]
for i in range(s.size - 2, -1, -1):
if s[i] == 0:
s[i] = s[i + 1]
s[s == 0] = 1
max_idx = np.where((s[:-1] > 0) & (s[1:] < 0))[0] + 1
x = np.arange(n, dtype=np.float32)
if max_idx.size == 0:
idx = np.array([0, n - 1], dtype=np.int64)
else:
idx = np.unique(np.concatenate(([0], max_idx, [n - 1]))).astype(np.int64)
env = np.interp(x, idx.astype(np.float32), a[idx]).astype(np.float32)
return -env, env
def normalize_projector(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Нормировка через проекцию между огибающими калибровки в диапазон [-1000, +1000]."""
w = min(raw.size, calib.size)
if w <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
raw_seg = np.asarray(raw[:w], dtype=np.float32)
lower, upper = build_calib_envelopes(np.asarray(calib[:w], dtype=np.float32))
span = upper - lower
finite_span = span[np.isfinite(span) & (span > 0)]
if finite_span.size > 0:
eps = max(float(np.median(finite_span)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = (
np.isfinite(raw_seg)
& np.isfinite(lower)
& np.isfinite(upper)
& (span > eps)
)
if np.any(valid):
proj = np.empty_like(raw_seg, dtype=np.float32)
proj[valid] = ((2.0 * (raw_seg[valid] - lower[valid]) / span[valid]) - 1.0) * 1000.0
proj[valid] = np.clip(proj[valid], -1000.0, 1000.0)
proj[~valid] = np.nan
out[:w] = proj
return out
def normalize_by_calib(raw: np.ndarray, calib: np.ndarray, norm_type: str) -> np.ndarray:
"""Нормировка свипа по выбранному алгоритму."""
nt = str(norm_type).strip().lower()
if nt == "simple":
return normalize_simple(raw, calib)
return normalize_projector(raw, calib)
def normalize_by_envelope(raw: np.ndarray, envelope: np.ndarray) -> np.ndarray:
"""Нормировка свипа через проекцию на огибающую из файла.
Воспроизводит логику normalize_projector: проецирует raw в [-1000, +1000]
используя готовую верхнюю огибающую (upper = envelope, lower = -envelope).
"""
w = min(raw.size, envelope.size)
if w <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
raw_seg = np.asarray(raw[:w], dtype=np.float32)
upper = np.asarray(envelope[:w], dtype=np.float32)
lower = -upper
span = upper - lower # = 2 * upper
finite_span = span[np.isfinite(span) & (span > 0)]
if finite_span.size > 0:
eps = max(float(np.median(finite_span)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = (
np.isfinite(raw_seg)
& np.isfinite(lower)
& np.isfinite(upper)
& (span > eps)
)
if np.any(valid):
proj = np.empty_like(raw_seg, dtype=np.float32)
proj[valid] = ((2.0 * (raw_seg[valid] - lower[valid]) / span[valid]) - 1.0) * 1000.0
proj[valid] = np.clip(proj[valid], -1000.0, 1000.0)
proj[~valid] = np.nan
out[:w] = proj
return out

View File

@ -1,209 +0,0 @@
"""Peak-search helpers for FFT visualizations."""
from __future__ import annotations
from typing import Dict, List, Optional
import numpy as np
def find_peak_width_markers(xs: np.ndarray, ys: np.ndarray) -> Optional[Dict[str, float]]:
"""Find the dominant non-zero peak and its half-height width."""
x_arr = np.asarray(xs, dtype=np.float64)
y_arr = np.asarray(ys, dtype=np.float64)
valid = np.isfinite(x_arr) & np.isfinite(y_arr) & (x_arr > 0.0)
if int(np.count_nonzero(valid)) < 3:
return None
x = x_arr[valid]
y = y_arr[valid]
x_min = float(x[0])
x_max = float(x[-1])
x_span = x_max - x_min
central_mask = (x >= (x_min + 0.25 * x_span)) & (x <= (x_min + 0.75 * x_span))
if int(np.count_nonzero(central_mask)) > 0:
central_idx = np.flatnonzero(central_mask)
peak_idx = int(central_idx[int(np.argmax(y[central_mask]))])
else:
peak_idx = int(np.argmax(y))
peak_y = float(y[peak_idx])
shoulder_gap = max(1, min(8, y.size // 64 if y.size > 0 else 1))
shoulder_width = max(4, min(32, y.size // 16 if y.size > 0 else 4))
left_lo = max(0, peak_idx - shoulder_gap - shoulder_width)
left_hi = max(0, peak_idx - shoulder_gap)
right_lo = min(y.size, peak_idx + shoulder_gap + 1)
right_hi = min(y.size, right_lo + shoulder_width)
background_parts = []
if left_hi > left_lo:
background_parts.append(float(np.nanmedian(y[left_lo:left_hi])))
if right_hi > right_lo:
background_parts.append(float(np.nanmedian(y[right_lo:right_hi])))
if background_parts:
background = float(np.mean(background_parts))
else:
background = float(np.nanpercentile(y, 10))
if not np.isfinite(peak_y) or not np.isfinite(background) or peak_y <= background:
return None
half_level = background + 0.5 * (peak_y - background)
def _interp_cross(x0: float, y0: float, x1: float, y1: float) -> float:
if not (np.isfinite(x0) and np.isfinite(y0) and np.isfinite(x1) and np.isfinite(y1)):
return x1
dy = y1 - y0
if dy == 0.0:
return x1
t = (half_level - y0) / dy
t = min(1.0, max(0.0, t))
return x0 + t * (x1 - x0)
left_x = float(x[0])
for i in range(peak_idx, 0, -1):
if y[i - 1] <= half_level <= y[i]:
left_x = _interp_cross(float(x[i - 1]), float(y[i - 1]), float(x[i]), float(y[i]))
break
right_x = float(x[-1])
for i in range(peak_idx, x.size - 1):
if y[i] >= half_level >= y[i + 1]:
right_x = _interp_cross(float(x[i]), float(y[i]), float(x[i + 1]), float(y[i + 1]))
break
width = right_x - left_x
if not np.isfinite(width) or width <= 0.0:
return None
return {
"background": background,
"left": left_x,
"right": right_x,
"width": width,
"amplitude": peak_y,
}
def rolling_median_ref(xs: np.ndarray, ys: np.ndarray, window_ghz: float) -> np.ndarray:
"""Compute a rolling median reference on a fixed-width X window."""
x = np.asarray(xs, dtype=np.float64)
y = np.asarray(ys, dtype=np.float64)
out = np.full(y.shape, np.nan, dtype=np.float64)
if x.size == 0 or y.size == 0 or x.size != y.size:
return out
width = float(window_ghz)
if not np.isfinite(width) or width <= 0.0:
return out
half = 0.5 * width
for i in range(x.size):
xi = x[i]
if not np.isfinite(xi):
continue
left = np.searchsorted(x, xi - half, side="left")
right = np.searchsorted(x, xi + half, side="right")
if right <= left:
continue
segment = y[left:right]
finite = np.isfinite(segment)
if not np.any(finite):
continue
out[i] = float(np.nanmedian(segment))
return out
def find_top_peaks_over_ref(
xs: np.ndarray,
ys: np.ndarray,
ref: np.ndarray,
top_n: int = 3,
) -> List[Dict[str, float]]:
"""Find the top-N non-overlapping peaks above a reference curve."""
x = np.asarray(xs, dtype=np.float64)
y = np.asarray(ys, dtype=np.float64)
r = np.asarray(ref, dtype=np.float64)
if x.size < 3 or y.size != x.size or r.size != x.size:
return []
valid = np.isfinite(x) & np.isfinite(y) & np.isfinite(r)
if not np.any(valid):
return []
delta = np.full_like(y, np.nan, dtype=np.float64)
delta[valid] = y[valid] - r[valid]
candidates: List[int] = []
for i in range(1, x.size - 1):
if not (np.isfinite(delta[i - 1]) and np.isfinite(delta[i]) and np.isfinite(delta[i + 1])):
continue
if delta[i] <= 0.0:
continue
left_ok = delta[i] > delta[i - 1]
right_ok = delta[i] >= delta[i + 1]
alt_left_ok = delta[i] >= delta[i - 1]
alt_right_ok = delta[i] > delta[i + 1]
if (left_ok and right_ok) or (alt_left_ok and alt_right_ok):
candidates.append(i)
if not candidates:
return []
candidates.sort(key=lambda i: float(delta[i]), reverse=True)
def _interp_cross(x0: float, y0: float, x1: float, y1: float, y_cross: float) -> float:
dy = y1 - y0
if not np.isfinite(dy) or dy == 0.0:
return x1
t = (y_cross - y0) / dy
t = min(1.0, max(0.0, t))
return x0 + t * (x1 - x0)
picked: List[Dict[str, float]] = []
for idx in candidates:
peak_y = float(y[idx])
peak_ref = float(r[idx])
peak_h = float(delta[idx])
if not (np.isfinite(peak_y) and np.isfinite(peak_ref) and np.isfinite(peak_h)) or peak_h <= 0.0:
continue
half_level = peak_ref + 0.5 * peak_h
left_x = float(x[0])
for i in range(idx, 0, -1):
y0 = float(y[i - 1])
y1 = float(y[i])
if np.isfinite(y0) and np.isfinite(y1) and (y0 <= half_level <= y1):
left_x = _interp_cross(float(x[i - 1]), y0, float(x[i]), y1, half_level)
break
right_x = float(x[-1])
for i in range(idx, x.size - 1):
y0 = float(y[i])
y1 = float(y[i + 1])
if np.isfinite(y0) and np.isfinite(y1) and (y0 >= half_level >= y1):
right_x = _interp_cross(float(x[i]), y0, float(x[i + 1]), y1, half_level)
break
width = float(right_x - left_x)
if not np.isfinite(width) or width <= 0.0:
continue
overlap = False
for peak in picked:
if not (right_x <= peak["left"] or left_x >= peak["right"]):
overlap = True
break
if overlap:
continue
picked.append(
{
"x": float(x[idx]),
"peak_y": peak_y,
"ref": peak_ref,
"height": peak_h,
"left": left_x,
"right": right_x,
"width": width,
}
)
if len(picked) >= int(max(1, top_n)):
break
picked.sort(key=lambda peak: peak["x"])
return picked

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,7 +0,0 @@
"""Runtime state helpers."""
from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.state.runtime_state import RuntimeState
__all__ = ["BackgroundMedianBuffer", "RingBuffer", "RuntimeState"]

View File

@ -0,0 +1,355 @@
"""Состояние приложения: текущие свипы и настройки калибровки/нормировки."""
from queue import Empty, Queue
from typing import Any, Mapping, Optional
import numpy as np
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 = DEFAULT_CALIB_ENVELOPE_PATH
BACKGROUND_PATH = DEFAULT_BACKGROUND_PATH
def format_status(data: Mapping[str, Any]) -> str:
"""Преобразовать словарь метрик в одну строку 'k:v'."""
def _fmt(v: Any) -> str:
if v is None:
return "NA"
try:
fv = float(v)
except Exception:
return str(v)
if not np.isfinite(fv):
return "nan"
if abs(fv) >= 1000 or (0 < abs(fv) < 0.01):
return f"{fv:.3g}"
return f"{fv:.3f}".rstrip("0").rstrip(".")
parts = [f"{k}:{_fmt(v)}" for k, v in data.items() if k != "pre_exp_sweep"]
return " ".join(parts)
class AppState:
"""Весь изменяемый 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.current_info: Optional[SweepInfo] = None
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 configure_capture_import(self, *, fancy: Optional[bool] = None, logscale: Optional[bool] = None):
self.preprocessor.set_capture_parse_options(fancy=fancy, logscale=logscale)
# ---- Свойства pipeline (для совместимости с GUI) ----
@property
def calib_enabled(self) -> bool:
return self.preprocessor.calib_enabled
@property
def calib_mode(self) -> str:
return self.preprocessor.calib_mode
@property
def calib_file_envelope(self) -> Optional[np.ndarray]:
return self.preprocessor.calib_file_envelope
@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):
self.preprocessor.set_calib_mode(mode)
self._refresh_current_processed()
def save_background(self, path: Optional[str] = None) -> bool:
return self.preprocessor.save_background(self._last_sweep_for_ring, path)
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: Optional[str] = None) -> bool:
return self.load_background_reference(path)
def set_background_enabled(self, enabled: bool):
self.preprocessor.set_background_enabled(enabled)
self._refresh_current_processed()
def set_calib_enabled(self, enabled: bool):
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.
Возвращает количество обработанных свипов.
"""
drained = 0
while True:
try:
s, info = q.get_nowait()
except Empty:
break
drained += 1
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
result = self.preprocessor.process(s, ch, update_references=True)
self._apply_result_to_current(result)
ring.ensure_init(s.size)
ring.push(result.processed_sweep)
return drained
def format_channel_label(self) -> str:
"""Строка с номерами каналов для подписи на графике."""
if self.current_info is None:
return ""
info = self.current_info
chs = info.get("chs") if isinstance(info, dict) else None
if chs is None:
chs = info.get("ch") if isinstance(info, dict) else None
if chs is None:
return ""
try:
if isinstance(chs, (list, tuple, set)):
ch_list = sorted(int(v) for v in chs)
return "chs " + ", ".join(str(v) for v in ch_list)
return f"chs {int(chs)}"
except Exception:
return f"chs {chs}"

View File

@ -1,49 +0,0 @@
"""Rolling median buffer for persisted FFT background capture."""
from __future__ import annotations
from typing import Optional
import numpy as np
class BackgroundMedianBuffer:
"""Store recent FFT rows and expose their median profile."""
def __init__(self, max_rows: int):
self.max_rows = max(1, int(max_rows))
self.width = 0
self.head = 0
self.count = 0
self.rows: Optional[np.ndarray] = None
def reset(self) -> None:
self.width = 0
self.head = 0
self.count = 0
self.rows = None
def push(self, fft_mag: np.ndarray) -> None:
values = np.asarray(fft_mag, dtype=np.float32).reshape(-1)
if values.size == 0:
return
if self.rows is None or self.width != values.size:
self.width = values.size
self.rows = np.full((self.max_rows, self.width), np.nan, dtype=np.float32)
self.head = 0
self.count = 0
self.rows[self.head, :] = values
self.head = (self.head + 1) % self.max_rows
self.count = min(self.count + 1, self.max_rows)
def median(self) -> Optional[np.ndarray]:
if self.rows is None or self.count <= 0:
return None
rows = self.rows[: self.count] if self.count < self.max_rows else self.rows
valid_rows = np.any(np.isfinite(rows), axis=1)
if not np.any(valid_rows):
return None
median = np.nanmedian(rows[valid_rows], axis=0).astype(np.float32, copy=False)
if not np.any(np.isfinite(median)):
return None
return np.nan_to_num(median, nan=0.0).astype(np.float32, copy=False)

View File

@ -1,193 +1,314 @@
"""Ring buffers for raw sweeps and FFT waterfall rows."""
from __future__ import annotations
"""Кольцевой буфер свипов и FFT-строк для водопадного отображения."""
import time
from typing import Optional
from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ, WF_WIDTH
from rfg_adc_plotter.processing.fft import compute_distance_axis, compute_fft_mag_row, fft_mag_to_db
from rfg_adc_plotter.constants import (
FREQ_MAX_GHZ,
FREQ_MIN_GHZ,
WF_WIDTH,
)
from rfg_adc_plotter.processing.fourier import (
build_frequency_axis_hz,
compute_ifft_profile_from_sweep,
perform_ifft_depth_response,
reconstruct_complex_spectrum_from_real_trace,
)
class RingBuffer:
"""Store raw sweeps, FFT rows, and matching time markers."""
"""Хранит последние N свипов и соответствующие FFT-строки.
Все мутабельные поля водопада инкапсулированы здесь,
что устраняет необходимость nonlocal в GUI-коде.
"""
def __init__(self, max_sweeps: int):
self.max_sweeps = int(max_sweeps)
self.fft_bins = FFT_LEN // 2 + 1
self.fft_mode = "symmetric"
self.width = 0
self.head = 0
self.ring: Optional[np.ndarray] = None
self.ring_time: Optional[np.ndarray] = None
self.ring_fft: Optional[np.ndarray] = None
self.max_sweeps = max_sweeps
# Размер IFFT-профиля теперь динамический и определяется по первому успешному свипу.
self.fft_bins = 0
self.fft_complex_mode: str = "arccos"
# Инициализируются при первом свипе (ensure_init)
self.ring: Optional[np.ndarray] = None # (max_sweeps, WF_WIDTH)
self.ring_fft: Optional[np.ndarray] = None # (max_sweeps, fft_bins)
self.ring_time: Optional[np.ndarray] = None # (max_sweeps,)
self.head: int = 0
self.width: Optional[int] = None
self.x_shared: Optional[np.ndarray] = None
self.distance_axis: Optional[np.ndarray] = None
self.last_fft_mag: Optional[np.ndarray] = None
self.last_fft_db: Optional[np.ndarray] = None
self.last_freqs: Optional[np.ndarray] = None
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 последнего свипа (для отображения без повторного вычисления)
self.last_fft_vals: Optional[np.ndarray] = None
# FFT-профили по третям входного частотного диапазона (для line-plot).
self.last_fft_third_axes_m: tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]] = (
None,
None,
None,
)
self.last_fft_third_vals: tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]] = (
None,
None,
None,
)
@property
def is_ready(self) -> bool:
return self.ring is not None and self.ring_fft is not None
return self.ring is not None
@property
def fft_symmetric(self) -> bool:
return self.fft_mode == "symmetric"
def fft_time_axis(self) -> Optional[np.ndarray]:
"""Legacy alias: старое имя поля (раньше было время в нс, теперь глубина в м)."""
return self.fft_depth_axis_m
def reset(self) -> None:
"""Drop all buffered sweeps and derived FFT state."""
self.width = 0
self.head = 0
self.ring = None
self.ring_time = None
self.ring_fft = None
self.x_shared = None
self.distance_axis = None
self.last_fft_mag = None
self.last_fft_db = None
self.last_freqs = None
self.y_min_fft = None
self.y_max_fft = None
def set_fft_complex_mode(self, mode: str) -> bool:
"""Выбрать режим реконструкции комплексного спектра для IFFT.
def ensure_init(self, sweep_width: int) -> bool:
"""Allocate or resize buffers. Returns True when geometry changed."""
target_width = max(int(sweep_width), int(WF_WIDTH))
changed = False
if self.ring is None or self.ring_time is None or self.ring_fft is None:
self.width = target_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)
self.head = 0
changed = True
elif target_width != self.width:
new_ring = np.full((self.max_sweeps, target_width), np.nan, dtype=np.float32)
take = min(self.width, target_width)
new_ring[:, :take] = self.ring[:, :take]
self.ring = new_ring
self.width = target_width
changed = True
if self.x_shared is None or self.x_shared.size != self.width:
self.x_shared = np.linspace(
SWEEP_FREQ_MIN_GHZ,
SWEEP_FREQ_MAX_GHZ,
self.width,
dtype=np.float32,
)
changed = True
return changed
def set_fft_mode(self, mode: str) -> bool:
"""Switch FFT mode and rebuild cached FFT rows from stored sweeps."""
normalized_mode = str(mode).strip().lower()
if normalized_mode in {"ordinary", "normal"}:
normalized_mode = "direct"
if normalized_mode in {"sym", "mirror"}:
normalized_mode = "symmetric"
if normalized_mode in {"positive-centered", "positive_centered", "zero_left"}:
normalized_mode = "positive_only"
if normalized_mode not in {"direct", "symmetric", "positive_only"}:
raise ValueError(f"Unsupported FFT mode: {mode!r}")
if normalized_mode == self.fft_mode:
Возвращает 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_mode = normalized_mode
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.last_fft_third_axes_m = (None, None, None)
self.last_fft_third_vals = (None, None, None)
self.y_min_fft = None
self.y_max_fft = None
if self.ring is None or self.ring_fft is None:
return True
self.ring_fft.fill(np.nan)
for row_idx in range(self.ring.shape[0]):
sweep_row = self.ring[row_idx]
if not np.any(np.isfinite(sweep_row)):
continue
fft_mag = compute_fft_mag_row(
sweep_row,
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
)
self.ring_fft[row_idx, :] = fft_mag
if self.last_freqs is not None:
self.distance_axis = compute_distance_axis(
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
)
last_idx = (self.head - 1) % self.max_sweeps
if self.ring_fft.shape[0] > 0:
last_fft = self.ring_fft[last_idx]
self.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(last_fft)
finite = self.ring_fft[np.isfinite(self.ring_fft)]
if finite.size > 0:
finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False))
self.y_min_fft = float(np.nanmin(finite_db))
self.y_max_fft = float(np.nanmax(finite_db))
return True
def set_symmetric_fft_enabled(self, enabled: bool) -> bool:
"""Backward-compatible wrapper for the old two-state FFT switch."""
return self.set_fft_mode("symmetric" if enabled else "direct")
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.head = 0
# Обновляем x_shared если пришёл свип большего размера
if self.x_shared is None or sweep_width > self.x_shared.size:
self.x_shared = np.linspace(FREQ_MIN_GHZ, FREQ_MAX_GHZ, sweep_width, dtype=np.float32)
def push(self, sweep: np.ndarray, freqs: Optional[np.ndarray] = None) -> None:
"""Push a processed sweep and refresh raw/FFT buffers."""
if sweep is None or sweep.size == 0:
def push(self, s: np.ndarray):
"""Добавить строку свипа в кольцевой буфер, вычислить FFT-строку."""
if s is None or s.size == 0 or self.ring is None:
return
self.ensure_init(int(sweep.size))
if self.ring is None or self.ring_time is None or self.ring_fft is None:
return
row = np.full((self.width,), np.nan, dtype=np.float32)
take = min(self.width, int(sweep.size))
row[:take] = np.asarray(sweep[:take], dtype=np.float32)
w = self.ring.shape[1]
row = np.full((w,), np.nan, dtype=np.float32)
take = min(w, s.size)
row[:take] = s[:take]
self.ring[self.head, :] = row
self.ring_time[self.head] = time.time()
if freqs is not None:
self.last_freqs = np.asarray(freqs, dtype=np.float64).copy()
self.head = (self.head + 1) % self.ring.shape[0]
fft_mag = compute_fft_mag_row(sweep, freqs, self.fft_bins, mode=self.fft_mode)
self.ring_fft[self.head, :] = fft_mag
self.last_fft_mag = np.asarray(fft_mag, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(fft_mag)
self._push_fft(s)
if self.last_fft_db.size > 0:
fr_min = float(np.nanmin(self.last_fft_db))
fr_max = float(np.nanmax(self.last_fft_db))
self.y_min_fft = fr_min if self.y_min_fft is None else min(self.y_min_fft, fr_min)
self.y_max_fft = fr_max if self.y_max_fft is None else max(self.y_max_fft, fr_max)
def _push_fft(self, s: np.ndarray):
empty_thirds = (
np.zeros((0,), dtype=np.float32),
np.zeros((0,), dtype=np.float32),
np.zeros((0,), dtype=np.float32),
)
depth_axis_m, fft_row = compute_ifft_profile_from_sweep(
s,
complex_mode=self.fft_complex_mode,
)
fft_row = np.asarray(fft_row, dtype=np.float32).ravel()
depth_axis_m = np.asarray(depth_axis_m, dtype=np.float32).ravel()
self.distance_axis = compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode)
self.head = (self.head + 1) % self.max_sweeps
n = min(int(fft_row.size), int(depth_axis_m.size))
if n <= 0:
self.last_fft_third_axes_m = empty_thirds
self.last_fft_third_vals = empty_thirds
return
if n != fft_row.size:
fft_row = fft_row[:n]
if n != depth_axis_m.size:
depth_axis_m = depth_axis_m[:n]
def get_display_raw(self) -> np.ndarray:
# Для отображения храним только первую половину IFFT-профиля:
# вторая половина для текущей схемы симметрична и визуально избыточна.
n_keep = max(1, (n + 1) // 2)
fft_row = fft_row[:n_keep]
depth_axis_m = depth_axis_m[:n_keep]
n = n_keep
needs_reset = (
self.ring_fft is None
or self.fft_depth_axis_m is None
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
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
self.last_fft_third_axes_m, self.last_fft_third_vals = self._compute_fft_thirds(s)
fr_min = np.nanmin(fft_row)
fr_max = float(np.nanpercentile(fft_row, 90))
if self.y_min_fft is None or (not np.isnan(fr_min) and fr_min < self.y_min_fft):
self.y_min_fft = float(fr_min)
if self.y_max_fft is None or (not np.isnan(fr_max) and fr_max > self.y_max_fft):
self.y_max_fft = float(fr_max)
def _compute_fft_thirds(
self, s: np.ndarray
) -> tuple[tuple[np.ndarray, np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray, np.ndarray]]:
sweep = np.asarray(s, dtype=np.float64).ravel()
total = int(sweep.size)
def _empty() -> np.ndarray:
return np.zeros((0,), dtype=np.float32)
if total <= 0:
return (_empty(), _empty(), _empty()), (_empty(), _empty(), _empty())
freq_hz = build_frequency_axis_hz(total)
edges = np.linspace(0, total, 4, dtype=np.int64)
axes: list[np.ndarray] = []
vals: list[np.ndarray] = []
for idx in range(3):
i0 = int(edges[idx])
i1 = int(edges[idx + 1])
if i1 - i0 < 2:
axes.append(_empty())
vals.append(_empty())
continue
seg = sweep[i0:i1]
seg_freq = freq_hz[i0:i1]
seg_complex = reconstruct_complex_spectrum_from_real_trace(
seg,
complex_mode=self.fft_complex_mode,
)
depth_m, seg_fft = perform_ifft_depth_response(seg_complex, seg_freq, axis="abs")
depth_m = np.asarray(depth_m, dtype=np.float32).ravel()
seg_fft = np.asarray(seg_fft, dtype=np.float32).ravel()
n = min(int(depth_m.size), int(seg_fft.size))
if n <= 0:
axes.append(_empty())
vals.append(_empty())
continue
depth_m = depth_m[:n]
seg_fft = seg_fft[:n]
n_keep = max(1, (n + 1) // 2)
axes.append(depth_m[:n_keep])
vals.append(seg_fft[:n_keep])
return (
axes[0],
axes[1],
axes[2],
), (
vals[0],
vals[1],
vals[2],
)
def get_display_ring(self) -> np.ndarray:
"""Кольцо в порядке от старого к новому, ось времени по X. Форма: (width, time)."""
if self.ring is None:
return np.zeros((1, 1), dtype=np.float32)
base = self.ring if self.head == 0 else np.roll(self.ring, -self.head, axis=0)
return base.T
return base.T # (width, time)
def get_display_fft_linear(self) -> np.ndarray:
def get_display_ring_fft(self) -> np.ndarray:
"""FFT-кольцо в порядке от старого к новому. Форма: (bins, time)."""
if self.ring_fft is None:
return np.zeros((1, 1), dtype=np.float32)
base = self.ring_fft if self.head == 0 else np.roll(self.ring_fft, -self.head, axis=0)
return base.T
def get_last_fft_linear(self) -> Optional[np.ndarray]:
if self.last_fft_mag is None:
return None
return np.asarray(self.last_fft_mag, dtype=np.float32).copy()
return base.T # (bins, time)
def get_display_times(self) -> Optional[np.ndarray]:
"""Временные метки строк в порядке от старого к новому."""
if self.ring_time is None:
return None
return self.ring_time if self.head == 0 else np.roll(self.ring_time, -self.head)
def subtract_recent_mean_fft(
self, disp_fft: np.ndarray, spec_mean_sec: float
) -> np.ndarray:
"""Вычесть среднее по каждой частоте за последние spec_mean_sec секунд."""
if spec_mean_sec <= 0.0:
return disp_fft
disp_times = self.get_display_times()
if disp_times is None:
return disp_fft
now_t = time.time()
mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec))
if not np.any(mask):
return disp_fft
try:
mean_spec = np.nanmean(disp_fft[:, mask], axis=1)
except Exception:
return disp_fft
mean_spec = np.nan_to_num(mean_spec, nan=0.0)
return disp_fft - mean_spec[:, None]
def compute_fft_levels(
self, disp_fft: np.ndarray, spec_clip: Optional[Tuple[float, float]]
) -> Optional[Tuple[float, float]]:
"""Вычислить (vmin, vmax) для отображения водопада спектров."""
# 1. По среднему спектру за видимое время
try:
mean_spec = np.nanmean(disp_fft, axis=1)
vmin_v = float(np.nanmin(mean_spec))
vmax_v = float(np.nanmax(mean_spec))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
return (vmin_v, vmax_v)
except Exception:
pass
# 2. Процентильная обрезка
if spec_clip is not None:
try:
vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0]))
vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1]))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
return (vmin_v, vmax_v)
except Exception:
pass
# 3. Глобальные накопленные мин/макс
if (
self.y_min_fft is not None
and self.y_max_fft is not None
and np.isfinite(self.y_min_fft)
and np.isfinite(self.y_max_fft)
and self.y_min_fft != self.y_max_fft
):
return (self.y_min_fft, self.y_max_fft)
return None

View File

@ -1,46 +0,0 @@
"""Mutable state container for the PyQtGraph backend."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import numpy as np
from rfg_adc_plotter.constants import BACKGROUND_MEDIAN_SWEEPS
from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo
@dataclass
class RuntimeState:
ring: RingBuffer
range_min_ghz: float = 0.0
range_max_ghz: float = 0.0
full_current_freqs: Optional[np.ndarray] = None
full_current_sweep_raw: Optional[np.ndarray] = None
full_current_aux_curves: SweepAuxCurves = None
current_freqs: Optional[np.ndarray] = None
current_distances: Optional[np.ndarray] = None
current_sweep_raw: Optional[np.ndarray] = None
current_aux_curves: SweepAuxCurves = None
current_sweep_norm: Optional[np.ndarray] = None
current_fft_mag: Optional[np.ndarray] = None
current_fft_db: Optional[np.ndarray] = None
last_calib_sweep: Optional[np.ndarray] = None
calib_envelope: Optional[np.ndarray] = None
calib_file_path: Optional[str] = None
background_buffer: BackgroundMedianBuffer = field(
default_factory=lambda: BackgroundMedianBuffer(BACKGROUND_MEDIAN_SWEEPS)
)
background_profile: Optional[np.ndarray] = None
background_file_path: Optional[str] = None
current_info: Optional[SweepInfo] = None
current_peak_width: Optional[float] = None
current_peak_amplitude: Optional[float] = None
peak_candidates: List[Dict[str, float]] = field(default_factory=list)
plot_dirty: bool = False
def mark_dirty(self) -> None:
self.plot_dirty = True

View File

@ -1,31 +1,7 @@
"""Shared runtime and parser types."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, TypeAlias, Union
from typing import Any, Dict, Tuple, Union
import numpy as np
Number = Union[int, float]
SweepInfo = Dict[str, Any]
SweepData = Dict[str, np.ndarray]
SweepAuxCurves = Optional[Tuple[np.ndarray, np.ndarray]]
SweepPacket = Tuple[np.ndarray, SweepInfo, SweepAuxCurves]
@dataclass(frozen=True)
class StartEvent:
ch: Optional[int] = None
@dataclass(frozen=True)
class PointEvent:
ch: int
x: int
y: float
aux: Optional[Tuple[float, float]] = None
ParserEvent: TypeAlias = Union[StartEvent, PointEvent]
SweepPacket = Tuple[np.ndarray, SweepInfo]

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

@ -1,44 +0,0 @@
from __future__ import annotations
import numpy as np
import unittest
from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer
class BackgroundMedianBufferTests(unittest.TestCase):
def test_buffer_returns_median_for_partial_fill(self):
buffer = BackgroundMedianBuffer(max_rows=4)
buffer.push(np.asarray([1.0, 5.0, 9.0], dtype=np.float32))
buffer.push(np.asarray([3.0, 7.0, 11.0], dtype=np.float32))
median = buffer.median()
self.assertIsNotNone(median)
self.assertTrue(np.allclose(median, np.asarray([2.0, 6.0, 10.0], dtype=np.float32)))
def test_buffer_wraparound_keeps_latest_rows(self):
buffer = BackgroundMedianBuffer(max_rows=2)
buffer.push(np.asarray([1.0, 5.0], dtype=np.float32))
buffer.push(np.asarray([3.0, 7.0], dtype=np.float32))
buffer.push(np.asarray([9.0, 11.0], dtype=np.float32))
median = buffer.median()
self.assertIsNotNone(median)
self.assertTrue(np.allclose(median, np.asarray([6.0, 9.0], dtype=np.float32)))
def test_buffer_reset_clears_state(self):
buffer = BackgroundMedianBuffer(max_rows=2)
buffer.push(np.asarray([1.0, 2.0], dtype=np.float32))
buffer.reset()
self.assertIsNone(buffer.rows)
self.assertIsNone(buffer.median())
self.assertEqual(buffer.count, 0)
self.assertEqual(buffer.head, 0)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,84 @@
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"
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"
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_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

@ -1,42 +0,0 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, *args],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
class CliTests(unittest.TestCase):
def test_wrapper_help_works(self):
proc = _run("RFG_ADC_dataplotter.py", "--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("usage:", proc.stdout)
self.assertIn("--peak_search", proc.stdout)
def test_module_help_works(self):
proc = _run("-m", "rfg_adc_plotter.main", "--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("usage:", proc.stdout)
self.assertIn("--parser_16_bit_x2", proc.stdout)
def test_backend_mpl_reports_removal(self):
proc = _run("-m", "rfg_adc_plotter.main", "/dev/null", "--backend", "mpl")
self.assertNotEqual(proc.returncode, 0)
self.assertIn("Matplotlib backend removed", proc.stderr)
if __name__ == "__main__":
unittest.main()

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

@ -1,275 +0,0 @@
from __future__ import annotations
import os
import tempfile
import numpy as np
import unittest
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.gui.pyqtgraph_backend import (
apply_working_range,
apply_working_range_to_aux_curves,
compute_background_subtracted_bscan_levels,
resolve_visible_aux_curves,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
calibrate_freqs,
load_calib_envelope,
recalculate_calibration_c,
save_calib_envelope,
)
from rfg_adc_plotter.processing.background import (
load_fft_background,
save_fft_background,
subtract_fft_background,
)
from rfg_adc_plotter.processing.fft import (
build_positive_only_centered_ifft_spectrum,
build_symmetric_ifft_spectrum,
compute_distance_axis,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
normalize_by_calib,
normalize_by_envelope,
resample_envelope,
)
from rfg_adc_plotter.processing.peaks import find_peak_width_markers, find_top_peaks_over_ref, rolling_median_ref
class ProcessingTests(unittest.TestCase):
def test_recalculate_calibration_preserves_requested_edges(self):
coeffs = recalculate_calibration_c(np.asarray([0.0, 1.0, 0.025], dtype=np.float64), 3.3, 14.3)
y0 = coeffs[0] + coeffs[1] * 3.3 + coeffs[2] * (3.3 ** 2)
y1 = coeffs[0] + coeffs[1] * 14.3 + coeffs[2] * (14.3 ** 2)
self.assertTrue(np.isclose(y0, 3.3))
self.assertTrue(np.isclose(y1, 14.3))
def test_calibrate_freqs_returns_monotonic_axis_and_same_shape(self):
sweep = {"F": np.linspace(3.3, 14.3, 32), "I": np.linspace(-1.0, 1.0, 32)}
calibrated = calibrate_freqs(sweep)
self.assertEqual(calibrated["F"].shape, (32,))
self.assertEqual(calibrated["I"].shape, (32,))
self.assertTrue(np.all(np.diff(calibrated["F"]) >= 0.0))
def test_normalizers_and_envelopes_return_finite_ranges(self):
calib = (np.sin(np.linspace(0.0, 4.0 * np.pi, 64)) * 5.0).astype(np.float32)
raw = calib * 0.75
lower, upper = build_calib_envelopes(calib)
self.assertEqual(lower.shape, calib.shape)
self.assertEqual(upper.shape, calib.shape)
self.assertTrue(np.all(lower <= upper))
self.assertTrue(np.all(np.isfinite(upper)))
self.assertLess(
float(np.mean(np.abs(np.diff(upper, n=2)))),
float(np.mean(np.abs(np.diff(calib, n=2)))),
)
simple = normalize_by_calib(raw, calib + 10.0, norm_type="simple")
projector = normalize_by_calib(raw, calib, norm_type="projector")
self.assertEqual(simple.shape, raw.shape)
self.assertEqual(projector.shape, raw.shape)
self.assertTrue(np.any(np.isfinite(simple)))
self.assertTrue(np.any(np.isfinite(projector)))
def test_file_calibration_envelope_roundtrip_and_division(self):
raw = (np.sin(np.linspace(0.0, 8.0 * np.pi, 128)) * 50.0 + 100.0).astype(np.float32)
envelope = build_calib_envelope(raw)
normalized = normalize_by_envelope(raw, envelope)
resampled = resample_envelope(envelope, 96)
self.assertEqual(envelope.shape, raw.shape)
self.assertEqual(normalized.shape, raw.shape)
self.assertEqual(resampled.shape, (96,))
self.assertTrue(np.any(np.isfinite(normalized)))
self.assertTrue(np.all(np.isfinite(envelope)))
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "calibration_envelope")
saved_path = save_calib_envelope(path, envelope)
loaded = load_calib_envelope(saved_path)
self.assertTrue(saved_path.endswith(".npy"))
self.assertTrue(np.allclose(loaded, envelope))
def test_normalize_by_envelope_adds_small_epsilon_to_zero_denominator(self):
raw = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
envelope = np.asarray([0.0, 1.0, -1.0], dtype=np.float32)
normalized = normalize_by_envelope(raw, envelope)
self.assertTrue(np.all(np.isfinite(normalized)))
self.assertGreater(normalized[0], 1e8)
self.assertAlmostEqual(float(normalized[1]), 2.0, places=5)
self.assertAlmostEqual(float(normalized[2]), -3.0, places=5)
def test_load_calib_envelope_rejects_empty_payload(self):
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "empty.npy")
np.save(path, np.zeros((0,), dtype=np.float32))
with self.assertRaises(ValueError):
load_calib_envelope(path)
def test_fft_background_roundtrip_and_rejects_non_1d_payload(self):
background = np.asarray([0.5, 1.5, 2.5], dtype=np.float32)
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "fft_background")
saved_path = save_fft_background(path, background)
loaded = load_fft_background(saved_path)
self.assertTrue(saved_path.endswith(".npy"))
self.assertTrue(np.allclose(loaded, background))
invalid_path = os.path.join(tmp_dir, "fft_background_invalid.npy")
np.save(invalid_path, np.zeros((2, 2), dtype=np.float32))
with self.assertRaises(ValueError):
load_fft_background(invalid_path)
def test_subtract_fft_background_clamps_negative_residuals_to_zero(self):
signal = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
background = np.asarray([1.0, 1.5, 5.0], dtype=np.float32)
subtracted = subtract_fft_background(signal, background)
self.assertTrue(np.allclose(subtracted, np.asarray([0.0, 0.5, 0.0], dtype=np.float32)))
self.assertTrue(np.allclose(subtract_fft_background(signal, signal), 0.0))
def test_apply_working_range_crops_sweep_to_selected_band(self):
freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64)
sweep = np.arange(12, dtype=np.float32)
cropped_freqs, cropped_sweep = apply_working_range(freqs, sweep, 5.0, 9.0)
self.assertGreater(cropped_freqs.size, 0)
self.assertEqual(cropped_freqs.shape, cropped_sweep.shape)
self.assertGreaterEqual(float(np.min(cropped_freqs)), 5.0)
self.assertLessEqual(float(np.max(cropped_freqs)), 9.0)
def test_apply_working_range_returns_empty_when_no_points_match(self):
freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64)
sweep = np.arange(12, dtype=np.float32)
cropped_freqs, cropped_sweep = apply_working_range(freqs, sweep, 20.0, 21.0)
self.assertEqual(cropped_freqs.shape, (0,))
self.assertEqual(cropped_sweep.shape, (0,))
def test_apply_working_range_to_aux_curves_uses_same_mask_as_raw_sweep(self):
freqs = np.linspace(3.3, 14.3, 6, dtype=np.float64)
sweep = np.asarray([0.0, 1.0, np.nan, 3.0, 4.0, 5.0], dtype=np.float32)
aux = (
np.asarray([10.0, 11.0, 12.0, 13.0, 14.0, 15.0], dtype=np.float32),
np.asarray([20.0, 21.0, 22.0, 23.0, 24.0, 25.0], dtype=np.float32),
)
cropped_freqs, cropped_sweep = apply_working_range(freqs, sweep, 4.0, 12.5)
cropped_aux = apply_working_range_to_aux_curves(freqs, sweep, aux, 4.0, 12.5)
self.assertIsNotNone(cropped_aux)
self.assertEqual(cropped_aux[0].shape, cropped_freqs.shape)
self.assertEqual(cropped_aux[1].shape, cropped_freqs.shape)
self.assertEqual(cropped_aux[0].shape, cropped_sweep.shape)
self.assertTrue(np.allclose(cropped_aux[0], np.asarray([11.0, 13.0, 14.0], dtype=np.float32)))
self.assertTrue(np.allclose(cropped_aux[1], np.asarray([21.0, 23.0, 24.0], dtype=np.float32)))
def test_resolve_visible_aux_curves_obeys_checkbox_state(self):
aux = (
np.asarray([1.0, 2.0], dtype=np.float32),
np.asarray([3.0, 4.0], dtype=np.float32),
)
self.assertIsNone(resolve_visible_aux_curves(aux, enabled=False))
visible = resolve_visible_aux_curves(aux, enabled=True)
self.assertIsNotNone(visible)
self.assertTrue(np.allclose(visible[0], aux[0]))
self.assertTrue(np.allclose(visible[1], aux[1]))
def test_background_subtracted_bscan_levels_ignore_zero_floor(self):
disp_fft_lin = np.zeros((4, 8), dtype=np.float32)
disp_fft_lin[1, 2:6] = np.asarray([0.05, 0.1, 0.5, 2.0], dtype=np.float32)
disp_fft_lin[2, 1:6] = np.asarray([0.08, 0.2, 0.7, 3.0, 9.0], dtype=np.float32)
disp_fft = fft_mag_to_db(disp_fft_lin)
levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft)
self.assertIsNotNone(levels)
positive_vals = disp_fft[disp_fft_lin > 0.0]
self.assertAlmostEqual(levels[0], float(np.nanpercentile(positive_vals, 15.0)), places=5)
self.assertAlmostEqual(levels[1], float(np.nanpercentile(positive_vals, 99.7)), places=5)
zero_floor = disp_fft[disp_fft_lin == 0.0]
self.assertLess(float(np.nanmax(zero_floor)), levels[0])
def test_background_subtracted_bscan_levels_fallback_when_residuals_too_sparse(self):
disp_fft_lin = np.zeros((3, 4), dtype=np.float32)
disp_fft_lin[1, 2] = 1.0
disp_fft = fft_mag_to_db(disp_fft_lin)
levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft)
self.assertIsNone(levels)
def test_fft_helpers_return_expected_shapes(self):
sweep = np.sin(np.linspace(0.0, 4.0 * np.pi, 128)).astype(np.float32)
freqs = np.linspace(3.3, 14.3, 128, dtype=np.float64)
mag = compute_fft_mag_row(sweep, freqs, 513)
row = compute_fft_row(sweep, freqs, 513)
axis = compute_distance_axis(freqs, 513)
self.assertEqual(mag.shape, (513,))
self.assertEqual(row.shape, (513,))
self.assertEqual(axis.shape, (513,))
self.assertTrue(np.all(np.diff(axis) >= 0.0))
def test_symmetric_ifft_spectrum_has_zero_gap_and_mirrored_band(self):
sweep = np.linspace(1.0, 2.0, 128, dtype=np.float32)
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
spectrum = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
self.assertIsNotNone(spectrum)
freq_axis = np.linspace(-10.0, 10.0, FFT_LEN, dtype=np.float64)
neg_idx_all = np.flatnonzero(freq_axis <= (-4.0))
pos_idx_all = np.flatnonzero(freq_axis >= 4.0)
band_len = int(min(neg_idx_all.size, pos_idx_all.size))
neg_idx = neg_idx_all[:band_len]
pos_idx = pos_idx_all[-band_len:]
zero_mask = (freq_axis > (-4.0)) & (freq_axis < 4.0)
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.allclose(spectrum[neg_idx], spectrum[pos_idx][::-1]))
def test_positive_only_centered_spectrum_keeps_zeros_until_positive_min(self):
sweep = np.linspace(1.0, 2.0, 128, dtype=np.float32)
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
spectrum = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
self.assertIsNotNone(spectrum)
freq_axis = np.linspace(-10.0, 10.0, FFT_LEN, dtype=np.float64)
zero_mask = freq_axis < 4.0
pos_idx = np.flatnonzero(freq_axis >= 4.0)
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.any(np.abs(spectrum[pos_idx]) > 0.0))
def test_symmetric_distance_axis_uses_windowed_frequency_bounds(self):
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
axis = compute_distance_axis(freqs, 513, mode="symmetric")
df_hz = (2.0 * 10.0 / max(1, FFT_LEN - 1)) * 1e9
expected_step = 299_792_458.0 / (2.0 * FFT_LEN * df_hz)
self.assertEqual(axis.shape, (513,))
self.assertTrue(np.all(np.diff(axis) >= 0.0))
self.assertAlmostEqual(float(axis[1] - axis[0]), expected_step, places=15)
def test_peak_helpers_find_reference_and_peak_boxes(self):
xs = np.linspace(0.0, 10.0, 200)
ys = np.exp(-((xs - 5.0) ** 2) / 0.4) * 10.0 + 1.0
ref = rolling_median_ref(xs, ys, 2.0)
peaks = find_top_peaks_over_ref(xs, ys, ref, top_n=3)
width = find_peak_width_markers(xs, ys)
self.assertEqual(ref.shape, ys.shape)
self.assertEqual(len(peaks), 1)
self.assertGreater(peaks[0]["x"], 4.0)
self.assertLess(peaks[0]["x"], 6.0)
self.assertIsNotNone(width)
self.assertGreater(width["width"], 0.0)
if __name__ == "__main__":
unittest.main()

View File

@ -1,90 +0,0 @@
from __future__ import annotations
import numpy as np
import unittest
from rfg_adc_plotter.state.ring_buffer import RingBuffer
class RingBufferTests(unittest.TestCase):
def test_ring_buffer_initializes_on_first_push(self):
ring = RingBuffer(max_sweeps=4)
sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
ring.push(sweep, np.linspace(3.3, 14.3, 64))
self.assertIsNotNone(ring.ring)
self.assertIsNotNone(ring.ring_fft)
self.assertIsNotNone(ring.ring_time)
self.assertIsNotNone(ring.distance_axis)
self.assertIsNotNone(ring.get_last_fft_linear())
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.ring.shape[0], 4)
self.assertEqual(ring.ring_fft.shape, (4, ring.fft_bins))
def test_ring_buffer_reallocates_when_sweep_width_grows(self):
ring = RingBuffer(max_sweeps=3)
ring.push(np.ones((32,), dtype=np.float32), np.linspace(3.3, 14.3, 32))
first_width = ring.width
ring.push(np.ones((2048,), dtype=np.float32), np.linspace(3.3, 14.3, 2048))
self.assertGreater(ring.width, first_width)
self.assertIsNotNone(ring.ring)
self.assertEqual(ring.ring.shape, (3, ring.width))
def test_ring_buffer_tracks_latest_fft_and_display_arrays(self):
ring = RingBuffer(max_sweeps=2)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), np.linspace(3.3, 14.3, 64))
ring.push(np.linspace(1.0, 0.0, 64, dtype=np.float32), np.linspace(3.3, 14.3, 64))
raw = ring.get_display_raw()
fft = ring.get_display_fft_linear()
self.assertEqual(raw.shape[1], 2)
self.assertEqual(fft.shape[1], 2)
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
def test_ring_buffer_can_switch_fft_mode_and_rebuild_fft_rows(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(sweep, freqs)
fft_before = ring.last_fft_db.copy()
axis_before = ring.distance_axis.copy()
changed = ring.set_symmetric_fft_enabled(False)
self.assertTrue(changed)
self.assertFalse(ring.fft_symmetric)
self.assertEqual(ring.get_display_raw().shape[1], 2)
self.assertIsNotNone(ring.get_last_fft_linear())
self.assertEqual(ring.last_fft_db.shape, fft_before.shape)
self.assertFalse(np.allclose(ring.last_fft_db, fft_before))
self.assertFalse(np.allclose(ring.distance_axis, axis_before))
def test_ring_buffer_can_switch_to_positive_only_fft_mode(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(sweep, freqs)
changed = ring.set_fft_mode("positive_only")
self.assertTrue(changed)
self.assertEqual(ring.fft_mode, "positive_only")
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
self.assertIsNotNone(ring.distance_axis)
def test_ring_buffer_reset_clears_cached_history(self):
ring = RingBuffer(max_sweeps=2)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), np.linspace(4.0, 10.0, 64))
ring.reset()
self.assertIsNone(ring.ring)
self.assertIsNone(ring.ring_fft)
self.assertIsNone(ring.distance_axis)
self.assertIsNone(ring.last_fft_db)
self.assertEqual(ring.width, 0)
self.assertEqual(ring.head, 0)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,101 @@
import numpy as np
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)
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.last_fft_third_axes_m != (None, None, None)
assert ring.last_fft_third_vals != (None, None, None)
for axis, vals in zip(ring.last_fft_third_axes_m, ring.last_fft_third_vals):
assert axis is not None
assert vals is not None
assert axis.dtype == np.float32
assert vals.dtype == np.float32
assert axis.size == vals.size
# 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()
assert ring.last_fft_third_axes_m != (None, None, None)
assert ring.last_fft_third_vals != (None, None, None)
changed = ring.set_fft_complex_mode("diff")
assert changed is True
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.last_fft_third_axes_m == (None, None, None)
assert ring.last_fft_third_vals == (None, None, None)
assert ring.fft_bins == 0
ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32))
assert ring.ring_fft is not None
assert ring.fft_depth_axis_m is not None
assert ring.last_fft_vals is not None
assert ring.last_fft_third_axes_m != (None, None, None)
assert ring.last_fft_third_vals != (None, None, None)
for axis, vals in zip(ring.last_fft_third_axes_m, ring.last_fft_third_vals):
assert axis is not None
assert vals is not None
assert axis.dtype == np.float32
assert vals.dtype == np.float32
assert axis.size == vals.size
def test_ring_buffer_short_sweeps_keep_third_profiles_well_formed():
for n in (1, 2, 3):
ring = RingBuffer(max_sweeps=4)
ring.ensure_init(n)
ring.push(np.linspace(-1.0, 1.0, n, dtype=np.float32))
assert ring.last_fft_third_axes_m != (None, None, None)
assert ring.last_fft_third_vals != (None, None, None)
for axis, vals in zip(ring.last_fft_third_axes_m, ring.last_fft_third_vals):
assert axis is not None
assert vals is not None
assert axis.dtype == np.float32
assert vals.dtype == np.float32
assert axis.size == vals.size

View File

@ -1,159 +0,0 @@
from __future__ import annotations
import math
import unittest
from rfg_adc_plotter.io.sweep_parser_core import (
AsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
ParserTestStreamParser,
PointEvent,
StartEvent,
SweepAssembler,
log_pair_to_sweep,
)
def _u16le(word: int) -> bytes:
w = int(word) & 0xFFFF
return bytes((w & 0xFF, (w >> 8) & 0xFF))
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:
value = int(value_i32) & 0xFFFF_FFFF
return b"".join(
[
_u16le(step),
_u16le((value >> 16) & 0xFFFF),
_u16le(value & 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, ch: int = 0) -> bytes:
a1 = int(avg1) & 0xFFFF_FFFF
a2 = int(avg2) & 0xFFFF_FFFF
return b"".join(
[
_u16le(step),
_u16le((a1 >> 16) & 0xFFFF),
_u16le(a1 & 0xFFFF),
_u16le((a2 >> 16) & 0xFFFF),
_u16le(a2 & 0xFFFF),
bytes((0x0A, int(ch) & 0xFF)),
]
)
def _pack_log16_start(ch: int) -> bytes:
return b"\xff\xff" * 3 + bytes((0x0A, int(ch) & 0xFF))
def _pack_log16_point(step: int, avg1: int, avg2: int) -> bytes:
return b"".join(
[
_u16le(step),
_u16le(avg1),
_u16le(avg2),
_u16le(0xFFFF),
]
)
class SweepParserCoreTests(unittest.TestCase):
def test_ascii_parser_emits_start_and_points(self):
parser = AsciiSweepParser()
events = parser.feed(b"Sweep_start\ns 1 2 -3\ns2 4 5\n")
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[1].ch, 1)
self.assertEqual(events[1].x, 2)
self.assertEqual(events[1].y, -3.0)
self.assertEqual(events[2].ch, 2)
self.assertEqual(events[2].x, 4)
self.assertEqual(events[2].y, 5.0)
def test_legacy_binary_parser_resynchronizes_after_garbage(self):
parser = LegacyBinaryParser()
stream = b"\x00junk" + _pack_legacy_start(3) + _pack_legacy_point(3, 1, -2)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 3)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 3)
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].y, -2.0)
def test_logscale_32_parser_keeps_channel_and_aux_values(self):
parser = LogScaleBinaryParser32()
stream = _pack_log_start(5) + _pack_log_point(7, 1500, 700, ch=5)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 5)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 5)
self.assertEqual(events[1].x, 7)
self.assertAlmostEqual(events[1].y, log_pair_to_sweep(1500, 700), places=6)
self.assertEqual(events[1].aux, (1500.0, 700.0))
def test_log_pair_to_sweep_is_order_independent(self):
self.assertAlmostEqual(log_pair_to_sweep(1500, 700), log_pair_to_sweep(700, 1500), places=6)
def test_logscale_16bit_parser_uses_last_start_channel(self):
parser = LogScale16BitX2BinaryParser()
stream = _pack_log16_start(2) + _pack_log16_point(1, 100, 90)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 2)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 2)
self.assertEqual(events[1].aux, (100.0, 90.0))
def test_parser_test_stream_parser_recovers_point_after_single_separator(self):
parser = ParserTestStreamParser()
stream = b"".join(
[
b"\xff\xff\xff\xff",
bytes((0x0A, 4)),
_u16le(1),
_u16le(100),
_u16le(90),
_u16le(0xFFFF),
]
)
events = parser.feed(stream)
events.extend(parser.feed(_u16le(2)))
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 4)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 4)
self.assertEqual(events[1].x, 1)
self.assertTrue(math.isfinite(events[1].y))
def test_sweep_assembler_builds_aux_curves_without_inversion(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(StartEvent(ch=1)))
assembler.consume(PointEvent(ch=1, x=1, y=10.0, aux=(100.0, 90.0)))
assembler.consume(PointEvent(ch=1, x=2, y=20.0, aux=(110.0, 95.0)))
sweep, info, aux = assembler.finalize_current()
self.assertEqual(sweep.shape[0], 3)
self.assertEqual(info["ch"], 1)
self.assertIsNotNone(aux)
self.assertEqual(aux[0][1], 100.0)
self.assertEqual(aux[1][2], 95.0)
if __name__ == "__main__":
unittest.main()