new background remove algoritm

This commit is contained in:
awe
2026-03-16 12:48:58 +03:00
parent b70df8c1bd
commit bacca8b9d5
11 changed files with 402 additions and 97 deletions

View File

@ -2,6 +2,7 @@
WF_WIDTH = 1000
FFT_LEN = 1024
BACKGROUND_MEDIAN_SWEEPS = 64
SWEEP_FREQ_MIN_GHZ = 3.3
SWEEP_FREQ_MAX_GHZ = 14.3

View File

@ -12,6 +12,11 @@ import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.io.sweep_reader import SweepReader
from rfg_adc_plotter.processing.background import (
load_fft_background,
save_fft_background,
subtract_fft_background,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
calibrate_freqs,
@ -21,7 +26,7 @@ from rfg_adc_plotter.processing.calibration import (
save_calib_envelope,
set_calibration_base_value,
)
from rfg_adc_plotter.processing.fft import compute_fft_row, fft_mag_to_db
from rfg_adc_plotter.processing.fft import compute_fft_mag_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 normalize_by_envelope, resample_envelope
from rfg_adc_plotter.processing.peaks import (
@ -248,9 +253,6 @@ def run_pyqtgraph(args) -> None:
spec_right_line.setVisible(False)
calib_cb = QtWidgets.QCheckBox("калибровка по огибающей")
bg_compute_cb = QtWidgets.QCheckBox("расчет фона")
bg_subtract_cb = QtWidgets.QCheckBox("вычет фона")
fft_bg_subtract_cb = QtWidgets.QCheckBox("FFT вычет фона")
range_group = QtWidgets.QGroupBox("Рабочий диапазон")
range_group_layout = QtWidgets.QFormLayout(range_group)
range_group_layout.setContentsMargins(6, 6, 6, 6)
@ -300,15 +302,39 @@ def run_pyqtgraph(args) -> None:
calib_buttons_row.addWidget(calib_save_btn)
calib_buttons_row.addWidget(calib_load_btn)
calib_group_layout.addLayout(calib_buttons_row)
background_group = QtWidgets.QGroupBox("Фон")
background_group_layout = QtWidgets.QVBoxLayout(background_group)
background_group_layout.setContentsMargins(6, 6, 6, 6)
background_group_layout.setSpacing(6)
background_cb = QtWidgets.QCheckBox("вычитание фона")
background_group_layout.addWidget(background_cb)
background_path_edit = QtWidgets.QLineEdit("fft_background.npy")
try:
background_path_edit.setPlaceholderText("fft_background.npy")
except Exception:
pass
background_path_row = QtWidgets.QHBoxLayout()
background_path_row.setContentsMargins(0, 0, 0, 0)
background_path_row.setSpacing(4)
background_path_row.addWidget(background_path_edit)
background_pick_btn = QtWidgets.QPushButton("Файл...")
background_path_row.addWidget(background_pick_btn)
background_group_layout.addLayout(background_path_row)
background_buttons_row = QtWidgets.QHBoxLayout()
background_buttons_row.setContentsMargins(0, 0, 0, 0)
background_buttons_row.setSpacing(4)
background_save_btn = QtWidgets.QPushButton("Сохранить медиану")
background_load_btn = QtWidgets.QPushButton("Загрузить")
background_buttons_row.addWidget(background_save_btn)
background_buttons_row.addWidget(background_load_btn)
background_group_layout.addLayout(background_buttons_row)
try:
settings_layout.addWidget(QtWidgets.QLabel("Настройки"))
except Exception:
pass
settings_layout.addWidget(range_group)
settings_layout.addWidget(calib_group)
settings_layout.addWidget(bg_compute_cb)
settings_layout.addWidget(bg_subtract_cb)
settings_layout.addWidget(fft_bg_subtract_cb)
settings_layout.addWidget(background_group)
settings_layout.addWidget(fft_mode_label)
settings_layout.addWidget(fft_mode_combo)
settings_layout.addWidget(peak_search_cb)
@ -317,9 +343,7 @@ def run_pyqtgraph(args) -> None:
win.addItem(status, row=3, col=0, colspan=2)
calib_enabled = False
bg_compute_enabled = True
bg_subtract_enabled = False
fft_bg_subtract_enabled = False
background_enabled = False
fft_mode = "symmetric"
status_note = ""
status_dirty = True
@ -422,11 +446,35 @@ def run_pyqtgraph(args) -> None:
path = ""
return path or "calibration_envelope.npy"
def get_background_file_path() -> str:
try:
path = background_path_edit.text().strip()
except Exception:
path = ""
return path or "fft_background.npy"
def reset_background_state(*, clear_profile: bool = True) -> None:
runtime.background_buffer.reset()
if clear_profile:
runtime.background_profile = None
runtime.current_fft_mag = None
runtime.current_fft_db = None
def resolve_active_background(expected_size: int) -> Optional[np.ndarray]:
if not background_enabled:
return None
if runtime.background_profile is None:
return None
if runtime.background_profile.size != int(expected_size):
set_status_note("фон: размер профиля не совпадает с FFT")
return None
return runtime.background_profile
def reset_ring_buffers() -> None:
runtime.ring.reset()
runtime.current_distances = None
runtime.current_fft_mag = None
runtime.current_fft_db = None
runtime.bg_spec_cache = None
runtime.current_peak_width = None
runtime.current_peak_amplitude = None
runtime.peak_candidates = []
@ -442,6 +490,7 @@ def run_pyqtgraph(args) -> None:
runtime.current_freqs = None
runtime.current_sweep_raw = None
runtime.current_sweep_norm = None
runtime.current_fft_mag = None
runtime.current_fft_db = None
runtime.current_distances = runtime.ring.distance_axis
return
@ -461,6 +510,7 @@ def run_pyqtgraph(args) -> None:
runtime.current_freqs = None
runtime.current_sweep_raw = None
runtime.current_sweep_norm = None
runtime.current_fft_mag = None
runtime.current_fft_db = None
runtime.current_distances = None
set_status_note("диапазон: нет точек в выбранном окне")
@ -479,6 +529,7 @@ def run_pyqtgraph(args) -> None:
else:
runtime.current_sweep_norm = None
runtime.current_fft_mag = None
runtime.current_fft_db = None
if (
not push_to_ring
@ -491,7 +542,10 @@ def run_pyqtgraph(args) -> None:
ensure_buffer(runtime.current_sweep_raw.size)
runtime.ring.push(sweep_for_processing, runtime.current_freqs)
runtime.current_distances = runtime.ring.distance_axis
runtime.current_fft_mag = runtime.ring.get_last_fft_linear()
runtime.current_fft_db = runtime.ring.last_fft_db
if runtime.current_fft_mag is not None:
runtime.background_buffer.push(runtime.current_fft_mag)
def set_calib_enabled() -> None:
nonlocal calib_enabled
@ -501,6 +555,7 @@ def run_pyqtgraph(args) -> None:
calib_enabled = False
if calib_enabled and runtime.calib_envelope is None:
set_status_note("калибровка: огибающая не загружена")
reset_background_state(clear_profile=True)
recompute_current_processed_sweep(push_to_ring=False)
runtime.mark_dirty()
@ -530,6 +585,7 @@ def run_pyqtgraph(args) -> None:
return
runtime.range_min_ghz = new_min
runtime.range_max_ghz = new_max
reset_background_state(clear_profile=True)
refresh_current_window(push_to_ring=True, reset_ring=True)
set_status_note(f"диапазон: {new_min:.6g}..{new_max:.6g} GHz")
runtime.mark_dirty()
@ -554,6 +610,26 @@ def run_pyqtgraph(args) -> None:
except Exception:
pass
def pick_background_file() -> None:
start_path = get_background_file_path()
try:
selected, _ = QtWidgets.QFileDialog.getSaveFileName(
main_window,
"Файл фона FFT",
start_path,
"NumPy (*.npy);;All files (*)",
)
except Exception as exc:
set_status_note(f"фон: выбор файла недоступен ({exc})")
runtime.mark_dirty()
return
if not selected:
return
try:
background_path_edit.setText(selected)
except Exception:
pass
def save_current_calibration() -> None:
if runtime.current_sweep_raw is None or runtime.current_sweep_raw.size == 0:
set_status_note("калибровка: нет текущего raw-свипа")
@ -574,6 +650,7 @@ def run_pyqtgraph(args) -> None:
calib_path_edit.setText(saved_path)
except Exception:
pass
reset_background_state(clear_profile=True)
recompute_current_processed_sweep(push_to_ring=False)
set_status_note(f"калибровка сохранена: {saved_path}")
runtime.mark_dirty()
@ -595,32 +672,67 @@ def run_pyqtgraph(args) -> None:
calib_path_edit.setText(normalized_path)
except Exception:
pass
reset_background_state(clear_profile=True)
recompute_current_processed_sweep(push_to_ring=False)
set_status_note(f"калибровка загружена: {normalized_path}")
runtime.mark_dirty()
def set_bg_compute_enabled() -> None:
nonlocal bg_compute_enabled
def save_current_background() -> None:
background = runtime.background_buffer.median()
if background is None or background.size == 0:
set_status_note("фон: буфер пуст, нечего сохранять")
runtime.mark_dirty()
return
try:
bg_compute_enabled = bool(bg_compute_cb.isChecked())
saved_path = save_fft_background(get_background_file_path(), background)
except Exception as exc:
set_status_note(f"фон: не удалось сохранить ({exc})")
runtime.mark_dirty()
return
runtime.background_profile = np.asarray(background, dtype=np.float32).copy()
runtime.background_file_path = saved_path
try:
background_path_edit.setText(saved_path)
except Exception:
bg_compute_enabled = False
pass
runtime.current_fft_db = None
set_status_note(f"фон сохранен: {saved_path}")
runtime.mark_dirty()
def set_bg_subtract_enabled() -> None:
nonlocal bg_subtract_enabled
def load_background_file() -> None:
path = get_background_file_path()
try:
bg_subtract_enabled = bool(bg_subtract_cb.isChecked())
background = load_fft_background(path)
except Exception as exc:
set_status_note(f"фон: не удалось загрузить ({exc})")
runtime.mark_dirty()
return
if background.size != fft_bins:
set_status_note(f"фон: ожидалось {fft_bins} FFT-бинов, получено {background.size}")
runtime.mark_dirty()
return
normalized_path = path if path.lower().endswith(".npy") else f"{path}.npy"
runtime.background_profile = np.asarray(background, dtype=np.float32).copy()
runtime.background_file_path = normalized_path
try:
background_path_edit.setText(normalized_path)
except Exception:
bg_subtract_enabled = False
pass
runtime.current_fft_db = None
set_status_note(f"фон загружен: {normalized_path}")
runtime.mark_dirty()
def set_fft_bg_subtract_enabled() -> None:
nonlocal fft_bg_subtract_enabled
def set_background_enabled() -> None:
nonlocal background_enabled
try:
fft_bg_subtract_enabled = bool(fft_bg_subtract_cb.isChecked())
background_enabled = bool(background_cb.isChecked())
except Exception:
fft_bg_subtract_enabled = False
background_enabled = False
if background_enabled and runtime.background_profile is None:
set_status_note("фон: профиль не загружен")
runtime.mark_dirty()
def set_fft_mode() -> None:
@ -630,7 +742,9 @@ def run_pyqtgraph(args) -> None:
except Exception:
fft_mode = "symmetric"
runtime.ring.set_fft_mode(fft_mode)
reset_background_state(clear_profile=True)
runtime.current_distances = runtime.ring.distance_axis
runtime.current_fft_mag = None
runtime.current_fft_db = None
mode_label = {
"direct": "IFFT: обычный",
@ -642,12 +756,11 @@ def run_pyqtgraph(args) -> None:
runtime.mark_dirty()
try:
bg_compute_cb.setChecked(True)
fft_mode_combo.setCurrentIndex(1)
except Exception:
pass
restore_range_controls()
set_bg_compute_enabled()
set_background_enabled()
set_fft_mode()
try:
@ -657,9 +770,10 @@ def run_pyqtgraph(args) -> None:
calib_pick_btn.clicked.connect(lambda _checked=False: pick_calib_file())
calib_save_btn.clicked.connect(lambda _checked=False: save_current_calibration())
calib_load_btn.clicked.connect(lambda _checked=False: load_calibration_file())
bg_compute_cb.stateChanged.connect(lambda _v: set_bg_compute_enabled())
bg_subtract_cb.stateChanged.connect(lambda _v: set_bg_subtract_enabled())
fft_bg_subtract_cb.stateChanged.connect(lambda _v: set_fft_bg_subtract_enabled())
background_cb.stateChanged.connect(lambda _v: set_background_enabled())
background_pick_btn.clicked.connect(lambda _checked=False: pick_background_file())
background_save_btn.clicked.connect(lambda _checked=False: save_current_background())
background_load_btn.clicked.connect(lambda _checked=False: load_background_file())
fft_mode_combo.currentIndexChanged.connect(lambda _v: set_fft_mode())
except Exception:
pass
@ -776,6 +890,7 @@ def run_pyqtgraph(args) -> None:
def apply_c_value(idx: int, edit) -> None:
try:
set_calibration_base_value(idx, float(edit.text().strip()))
reset_background_state(clear_profile=True)
runtime.mark_dirty()
except Exception:
try:
@ -821,38 +936,14 @@ def run_pyqtgraph(args) -> None:
except Exception:
pass
def visible_bg_fft(disp_fft: np.ndarray, force: bool = False) -> Optional[np.ndarray]:
nonlocal bg_compute_enabled, bg_subtract_enabled
need_bg = bool(bg_subtract_enabled or force)
if (not need_bg) or disp_fft.size == 0:
return None
ny, nx = disp_fft.shape
if ny <= 0 or nx <= 0:
return runtime.bg_spec_cache
if runtime.bg_spec_cache is not None and runtime.bg_spec_cache.size != ny:
runtime.bg_spec_cache = None
if not bg_compute_enabled:
return runtime.bg_spec_cache
try:
(x0, x1), _ = p_spec.viewRange()
except Exception:
x0, x1 = 0.0, float(nx - 1)
xmin, xmax = sorted((float(x0), float(x1)))
ix0 = max(0, min(nx - 1, int(np.floor(xmin))))
ix1 = max(0, min(nx - 1, int(np.ceil(xmax))))
if ix1 < ix0:
ix1 = ix0
window = disp_fft[:, ix0 : ix1 + 1]
if window.size == 0:
return runtime.bg_spec_cache
try:
bg_spec = np.nanmedian(window, axis=1)
except Exception:
return runtime.bg_spec_cache
if not np.any(np.isfinite(bg_spec)):
return runtime.bg_spec_cache
runtime.bg_spec_cache = np.nan_to_num(bg_spec, nan=0.0).astype(np.float32, copy=False)
return runtime.bg_spec_cache
def refresh_current_fft_cache(sweep_for_fft: np.ndarray, bins: int) -> None:
runtime.current_fft_mag = compute_fft_mag_row(
sweep_for_fft,
runtime.current_freqs,
bins,
mode=fft_mode,
)
runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag)
def drain_queue() -> int:
drained = 0
@ -897,12 +988,6 @@ def run_pyqtgraph(args) -> None:
changed = drain_queue() > 0
redraw_needed = changed or runtime.plot_dirty
bg_fft_for_line = None
if redraw_needed and fft_bg_subtract_enabled and runtime.ring.ring_fft is not None:
try:
bg_fft_for_line = visible_bg_fft(runtime.ring.get_display_fft_linear(), force=True)
except Exception:
bg_fft_for_line = None
if redraw_needed:
xs = resolve_curve_xs(
@ -952,25 +1037,24 @@ def run_pyqtgraph(args) -> None:
sweep_for_fft = runtime.current_sweep_norm if runtime.current_sweep_norm is not None else runtime.current_sweep_raw
distance_axis = runtime.current_distances if runtime.current_distances is not None else runtime.ring.distance_axis
if sweep_for_fft is not None and sweep_for_fft.size > 0 and distance_axis is not None:
if runtime.current_fft_db is None or runtime.current_fft_db.size != distance_axis.size or runtime.plot_dirty:
runtime.current_fft_db = compute_fft_row(
sweep_for_fft,
runtime.current_freqs,
distance_axis.size,
mode=fft_mode,
)
fft_vals = runtime.current_fft_db
xs_fft = distance_axis[: fft_vals.size]
if fft_bg_subtract_enabled and bg_fft_for_line is not None:
n_bg = int(min(fft_vals.size, bg_fft_for_line.size))
if n_bg > 0:
num = np.maximum(
np.power(10.0, np.asarray(fft_vals[:n_bg], dtype=np.float64) / 20.0),
0.0,
)
den = np.maximum(np.asarray(bg_fft_for_line[:n_bg], dtype=np.float64), 0.0)
fft_vals = (20.0 * np.log10((num + 1e-9) / (den + 1e-9))).astype(np.float32, copy=False)
xs_fft = xs_fft[:n_bg]
if runtime.current_fft_mag is None or runtime.current_fft_mag.size != distance_axis.size or runtime.plot_dirty:
refresh_current_fft_cache(sweep_for_fft, distance_axis.size)
fft_mag = runtime.current_fft_mag
xs_fft = distance_axis[: fft_mag.size]
active_background = None
try:
active_background = resolve_active_background(fft_mag.size)
except Exception:
active_background = None
display_fft_mag = fft_mag
if active_background is not None:
try:
display_fft_mag = subtract_fft_background(fft_mag, active_background)
except Exception as exc:
set_status_note(f"фон: не удалось применить ({exc})")
active_background = None
display_fft_mag = fft_mag
fft_vals = fft_mag_to_db(display_fft_mag)
curve_fft.setData(xs_fft, fft_vals)
finite_x = xs_fft[np.isfinite(xs_fft)]
if finite_x.size > 0:
@ -1006,7 +1090,7 @@ def run_pyqtgraph(args) -> None:
for box in fft_peak_boxes:
box.setVisible(False)
if fft_bg_subtract_enabled and bg_fft_for_line is not None:
if active_background is not None:
p_fft.setYRange(-10.0, 30.0, padding=0)
else:
finite_y = y_for_range[np.isfinite(y_for_range)]
@ -1109,7 +1193,7 @@ def run_pyqtgraph(args) -> None:
except Exception:
pass
if changed and runtime.ring.ring_fft is not None:
if redraw_needed and runtime.ring.ring_fft is not None:
disp_fft_lin = runtime.ring.get_display_fft_linear()
if spec_mean_sec > 0.0:
disp_times = runtime.ring.get_display_times()
@ -1124,16 +1208,21 @@ def run_pyqtgraph(args) -> None:
except Exception:
pass
bg_spec = visible_bg_fft(disp_fft_lin)
if bg_spec is not None:
num = np.maximum(disp_fft_lin, 0.0).astype(np.float32, copy=False) + 1e-9
den = bg_spec[:, None] + 1e-9
disp_fft = (20.0 * np.log10(num / den)).astype(np.float32, copy=False)
else:
disp_fft = fft_mag_to_db(disp_fft_lin)
active_background = None
try:
active_background = resolve_active_background(disp_fft_lin.shape[0])
except Exception:
active_background = None
if active_background is not None:
try:
disp_fft_lin = subtract_fft_background(disp_fft_lin, active_background)
except Exception as exc:
set_status_note(f"фон: не удалось применить ({exc})")
active_background = None
disp_fft = fft_mag_to_db(disp_fft_lin)
levels = None
if bg_spec is not None:
if active_background is not None:
try:
p5 = float(np.nanpercentile(disp_fft, 5))
p95 = float(np.nanpercentile(disp_fft, 95))

View File

@ -1,5 +1,11 @@
"""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,
@ -47,11 +53,15 @@ __all__ = [
"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

@ -0,0 +1,66 @@
"""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,6 +1,7 @@
"""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__ = ["RingBuffer", "RuntimeState"]
__all__ = ["BackgroundMedianBuffer", "RingBuffer", "RuntimeState"]

View File

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

@ -25,6 +25,7 @@ class RingBuffer:
self.ring_fft: Optional[np.ndarray] = 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.y_min_fft: Optional[float] = None
@ -47,6 +48,7 @@ class RingBuffer:
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
@ -125,6 +127,7 @@ class RingBuffer:
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:
@ -155,6 +158,7 @@ class RingBuffer:
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)
if self.last_fft_db.size > 0:
@ -178,6 +182,11 @@ class RingBuffer:
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()
def get_display_times(self) -> Optional[np.ndarray]:
if self.ring_time is None:
return None

View File

@ -7,6 +7,8 @@ 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
@ -23,12 +25,17 @@ class RuntimeState:
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
bg_spec_cache: Optional[np.ndarray] = None
current_peak_width: Optional[float] = None
current_peak_amplitude: Optional[float] = None
peak_candidates: List[Dict[str, float]] = field(default_factory=list)

View File

@ -0,0 +1,44 @@
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

@ -14,6 +14,11 @@ from rfg_adc_plotter.processing.calibration import (
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,
@ -101,6 +106,28 @@ class ProcessingTests(unittest.TestCase):
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)

View File

@ -15,6 +15,7 @@ class RingBufferTests(unittest.TestCase):
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))
@ -52,6 +53,7 @@ class RingBufferTests(unittest.TestCase):
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))