calib fix

This commit is contained in:
awe
2026-03-12 16:48:26 +03:00
parent c2a892f397
commit 157447a237
6 changed files with 327 additions and 59 deletions

View File

@ -13,14 +13,17 @@ import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ 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.io.sweep_reader import SweepReader
from rfg_adc_plotter.processing.calibration import ( from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
calibrate_freqs, calibrate_freqs,
get_calibration_base, get_calibration_base,
get_calibration_coeffs, get_calibration_coeffs,
load_calib_envelope,
save_calib_envelope,
set_calibration_base_value, 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_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.formatting import compute_auto_ylim, format_status_kv, parse_spec_clip
from rfg_adc_plotter.processing.normalization import normalize_by_calib from rfg_adc_plotter.processing.normalization import normalize_by_envelope, resample_envelope
from rfg_adc_plotter.processing.peaks import ( from rfg_adc_plotter.processing.peaks import (
find_peak_width_markers, find_peak_width_markers,
find_top_peaks_over_ref, find_top_peaks_over_ref,
@ -95,7 +98,6 @@ def run_pyqtgraph(args) -> None:
fft_bins = FFT_LEN // 2 + 1 fft_bins = FFT_LEN // 2 + 1
spec_clip = parse_spec_clip(getattr(args, "spec_clip", None)) spec_clip = parse_spec_clip(getattr(args, "spec_clip", None))
spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0))
norm_type = str(getattr(args, "norm_type", "projector")).strip().lower()
runtime = RuntimeState(ring=RingBuffer(max_sweeps)) runtime = RuntimeState(ring=RingBuffer(max_sweeps))
pg.setConfigOptions( pg.setConfigOptions(
@ -130,8 +132,8 @@ def run_pyqtgraph(args) -> None:
settings_layout.setContentsMargins(6, 6, 6, 6) settings_layout.setContentsMargins(6, 6, 6, 6)
settings_layout.setSpacing(8) settings_layout.setSpacing(8)
try: try:
settings_widget.setMinimumWidth(130) settings_widget.setMinimumWidth(250)
settings_widget.setMaximumWidth(150) settings_widget.setMaximumWidth(340)
except Exception: except Exception:
pass pass
main_layout.addWidget(settings_widget) main_layout.addWidget(settings_widget)
@ -201,16 +203,41 @@ def run_pyqtgraph(args) -> None:
spec_left_line.setVisible(False) spec_left_line.setVisible(False)
spec_right_line.setVisible(False) spec_right_line.setVisible(False)
calib_cb = QtWidgets.QCheckBox("нормировка") calib_cb = QtWidgets.QCheckBox("калибровка по огибающей")
bg_compute_cb = QtWidgets.QCheckBox("расчет фона") bg_compute_cb = QtWidgets.QCheckBox("расчет фона")
bg_subtract_cb = QtWidgets.QCheckBox("вычет фона") bg_subtract_cb = QtWidgets.QCheckBox("вычет фона")
fft_bg_subtract_cb = QtWidgets.QCheckBox("FFT вычет фона") fft_bg_subtract_cb = QtWidgets.QCheckBox("FFT вычет фона")
peak_search_cb = QtWidgets.QCheckBox("поиск пиков") peak_search_cb = QtWidgets.QCheckBox("поиск пиков")
calib_group = QtWidgets.QGroupBox("Калибровка")
calib_group_layout = QtWidgets.QVBoxLayout(calib_group)
calib_group_layout.setContentsMargins(6, 6, 6, 6)
calib_group_layout.setSpacing(6)
calib_group_layout.addWidget(calib_cb)
calib_path_edit = QtWidgets.QLineEdit("calibration_envelope.npy")
try:
calib_path_edit.setPlaceholderText("calibration_envelope.npy")
except Exception:
pass
calib_path_row = QtWidgets.QHBoxLayout()
calib_path_row.setContentsMargins(0, 0, 0, 0)
calib_path_row.setSpacing(4)
calib_path_row.addWidget(calib_path_edit)
calib_pick_btn = QtWidgets.QPushButton("Файл...")
calib_path_row.addWidget(calib_pick_btn)
calib_group_layout.addLayout(calib_path_row)
calib_buttons_row = QtWidgets.QHBoxLayout()
calib_buttons_row.setContentsMargins(0, 0, 0, 0)
calib_buttons_row.setSpacing(4)
calib_save_btn = QtWidgets.QPushButton("Сохранить текущую")
calib_load_btn = QtWidgets.QPushButton("Загрузить")
calib_buttons_row.addWidget(calib_save_btn)
calib_buttons_row.addWidget(calib_load_btn)
calib_group_layout.addLayout(calib_buttons_row)
try: try:
settings_layout.addWidget(QtWidgets.QLabel("Настройки")) settings_layout.addWidget(QtWidgets.QLabel("Настройки"))
except Exception: except Exception:
pass pass
settings_layout.addWidget(calib_cb) settings_layout.addWidget(calib_group)
settings_layout.addWidget(bg_compute_cb) settings_layout.addWidget(bg_compute_cb)
settings_layout.addWidget(bg_subtract_cb) settings_layout.addWidget(bg_subtract_cb)
settings_layout.addWidget(fft_bg_subtract_cb) settings_layout.addWidget(fft_bg_subtract_cb)
@ -223,6 +250,8 @@ def run_pyqtgraph(args) -> None:
bg_compute_enabled = True bg_compute_enabled = True
bg_subtract_enabled = False bg_subtract_enabled = False
fft_bg_subtract_enabled = False fft_bg_subtract_enabled = False
status_note = ""
status_dirty = True
fixed_ylim: Optional[Tuple[float, float]] = None fixed_ylim: Optional[Tuple[float, float]] = None
if args.ylim: if args.ylim:
try: try:
@ -270,8 +299,49 @@ def run_pyqtgraph(args) -> None:
img_fft.setRect(0, d_min, max_sweeps, d_max - d_min) img_fft.setRect(0, d_min, max_sweeps, d_max - d_min)
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(d_min, d_max), padding=0) p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(d_min, d_max), padding=0)
def normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: def resolve_curve_xs(size: int) -> np.ndarray:
return normalize_by_calib(raw, calib, norm_type=norm_type) if size <= 0:
return np.zeros((0,), dtype=np.float32)
if runtime.current_freqs is not None and runtime.current_freqs.size == size:
return runtime.current_freqs
if runtime.current_freqs is not None and runtime.current_freqs.size > 1:
return np.linspace(
float(runtime.current_freqs[0]),
float(runtime.current_freqs[-1]),
size,
dtype=np.float64,
)
if runtime.ring.x_shared is not None and size <= runtime.ring.x_shared.size:
return runtime.ring.x_shared[:size]
return np.arange(size, dtype=np.float32)
def set_status_note(note: str) -> None:
nonlocal status_note, status_dirty
status_note = str(note).strip()
status_dirty = True
def get_calib_file_path() -> str:
try:
path = calib_path_edit.text().strip()
except Exception:
path = ""
return path or "calibration_envelope.npy"
def recompute_current_processed_sweep(push_to_ring: bool = False) -> None:
if runtime.current_sweep_raw is not None and calib_enabled and runtime.calib_envelope is not None:
runtime.current_sweep_norm = normalize_by_envelope(runtime.current_sweep_raw, runtime.calib_envelope)
else:
runtime.current_sweep_norm = None
runtime.current_fft_db = None
if not push_to_ring or runtime.current_sweep_raw is None:
return
sweep_for_processing = runtime.current_sweep_norm if runtime.current_sweep_norm is not None else runtime.current_sweep_raw
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_db = runtime.ring.last_fft_db
def set_calib_enabled() -> None: def set_calib_enabled() -> None:
nonlocal calib_enabled nonlocal calib_enabled
@ -279,10 +349,74 @@ def run_pyqtgraph(args) -> None:
calib_enabled = bool(calib_cb.isChecked()) calib_enabled = bool(calib_cb.isChecked())
except Exception: except Exception:
calib_enabled = False calib_enabled = False
if calib_enabled and runtime.current_sweep_raw is not None and runtime.last_calib_sweep is not None: if calib_enabled and runtime.calib_envelope is None:
runtime.current_sweep_norm = normalize_sweep(runtime.current_sweep_raw, runtime.last_calib_sweep) set_status_note("калибровка: огибающая не загружена")
else: recompute_current_processed_sweep(push_to_ring=False)
runtime.current_sweep_norm = None runtime.mark_dirty()
def pick_calib_file() -> None:
start_path = get_calib_file_path()
try:
selected, _ = QtWidgets.QFileDialog.getSaveFileName(
main_window,
"Файл калибровки",
start_path,
"NumPy (*.npy);;All files (*)",
)
except Exception as exc:
set_status_note(f"калибровка: выбор файла недоступен ({exc})")
runtime.mark_dirty()
return
if not selected:
return
try:
calib_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-свипа")
runtime.mark_dirty()
return
try:
envelope = build_calib_envelope(runtime.current_sweep_raw)
saved_path = save_calib_envelope(get_calib_file_path(), envelope)
except Exception as exc:
set_status_note(f"калибровка: не удалось сохранить ({exc})")
runtime.mark_dirty()
return
runtime.calib_envelope = envelope
runtime.calib_file_path = saved_path
runtime.last_calib_sweep = None
try:
calib_path_edit.setText(saved_path)
except Exception:
pass
recompute_current_processed_sweep(push_to_ring=False)
set_status_note(f"калибровка сохранена: {saved_path}")
runtime.mark_dirty()
def load_calibration_file() -> None:
path = get_calib_file_path()
try:
envelope = load_calib_envelope(path)
except Exception as exc:
set_status_note(f"калибровка: не удалось загрузить ({exc})")
runtime.mark_dirty()
return
normalized_path = path if path.lower().endswith(".npy") else f"{path}.npy"
runtime.calib_envelope = envelope
runtime.calib_file_path = normalized_path
runtime.last_calib_sweep = None
try:
calib_path_edit.setText(normalized_path)
except Exception:
pass
recompute_current_processed_sweep(push_to_ring=False)
set_status_note(f"калибровка загружена: {normalized_path}")
runtime.mark_dirty() runtime.mark_dirty()
def set_bg_compute_enabled() -> None: def set_bg_compute_enabled() -> None:
@ -317,6 +451,9 @@ def run_pyqtgraph(args) -> None:
try: try:
calib_cb.stateChanged.connect(lambda _v: set_calib_enabled()) calib_cb.stateChanged.connect(lambda _v: set_calib_enabled())
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_compute_cb.stateChanged.connect(lambda _v: set_bg_compute_enabled())
bg_subtract_cb.stateChanged.connect(lambda _v: set_bg_subtract_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()) fft_bg_subtract_cb.stateChanged.connect(lambda _v: set_fft_bg_subtract_enabled())
@ -531,29 +668,7 @@ def run_pyqtgraph(args) -> None:
runtime.current_sweep_raw = calibrated["I"] runtime.current_sweep_raw = calibrated["I"]
runtime.current_aux_curves = aux_curves runtime.current_aux_curves = aux_curves
runtime.current_info = info runtime.current_info = info
recompute_current_processed_sweep(push_to_ring=True)
channel = 0
try:
channel = int(info.get("ch", 0)) if isinstance(info, dict) else 0
except Exception:
channel = 0
if channel == 0:
runtime.last_calib_sweep = runtime.current_sweep_raw
runtime.current_sweep_norm = None
sweep_for_processing = runtime.current_sweep_raw
else:
if calib_enabled and runtime.last_calib_sweep is not None:
runtime.current_sweep_norm = normalize_sweep(runtime.current_sweep_raw, runtime.last_calib_sweep)
sweep_for_processing = runtime.current_sweep_norm
else:
runtime.current_sweep_norm = None
sweep_for_processing = runtime.current_sweep_raw
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_db = runtime.ring.last_fft_db
if drained > 0: if drained > 0:
update_physical_axes() update_physical_axes()
return drained return drained
@ -569,7 +684,7 @@ def run_pyqtgraph(args) -> None:
pass pass
def update() -> None: def update() -> None:
nonlocal peak_ref_window nonlocal peak_ref_window, status_dirty
if peak_calibrate_mode and any(edit.hasFocus() for edit in c_edits): if peak_calibrate_mode and any(edit.hasFocus() for edit in c_edits):
return return
if peak_search_enabled and peak_window_edit is not None and peak_window_edit.hasFocus(): if peak_search_enabled and peak_window_edit is not None and peak_window_edit.hasFocus():
@ -584,26 +699,34 @@ def run_pyqtgraph(args) -> None:
except Exception: except Exception:
bg_fft_for_line = None bg_fft_for_line = None
if redraw_needed and runtime.current_sweep_raw is not None: if redraw_needed and (runtime.current_sweep_raw is not None or runtime.calib_envelope is not None):
xs = None xs = resolve_curve_xs(
if runtime.current_freqs is not None and runtime.current_freqs.size == runtime.current_sweep_raw.size: runtime.current_sweep_raw.size if runtime.current_sweep_raw is not None else runtime.calib_envelope.size
xs = runtime.current_freqs )
elif runtime.ring.x_shared is not None and runtime.current_sweep_raw.size <= runtime.ring.x_shared.size: displayed_calib = None
xs = runtime.ring.x_shared[: runtime.current_sweep_raw.size]
else:
xs = np.arange(runtime.current_sweep_raw.size)
curve.setData(xs, runtime.current_sweep_raw, autoDownsample=True) if runtime.current_sweep_raw is not None:
if runtime.current_aux_curves is not None: curve.setData(xs[: runtime.current_sweep_raw.size], runtime.current_sweep_raw, autoDownsample=True)
avg_1_curve, avg_2_curve = runtime.current_aux_curves if runtime.current_aux_curves is not None:
curve_avg1.setData(xs[: avg_1_curve.size], avg_1_curve, autoDownsample=True) avg_1_curve, avg_2_curve = runtime.current_aux_curves
curve_avg2.setData(xs[: avg_2_curve.size], avg_2_curve, autoDownsample=True) curve_avg1.setData(xs[: avg_1_curve.size], avg_1_curve, autoDownsample=True)
curve_avg2.setData(xs[: avg_2_curve.size], avg_2_curve, autoDownsample=True)
else:
curve_avg1.setData([], [])
curve_avg2.setData([], [])
else: else:
curve.setData([], [])
curve_avg1.setData([], []) curve_avg1.setData([], [])
curve_avg2.setData([], []) curve_avg2.setData([], [])
if runtime.last_calib_sweep is not None: if runtime.calib_envelope is not None:
curve_calib.setData(xs[: runtime.last_calib_sweep.size], runtime.last_calib_sweep, autoDownsample=True) if runtime.current_sweep_raw is not None:
displayed_calib = resample_envelope(runtime.calib_envelope, runtime.current_sweep_raw.size)
xs_calib = xs[: displayed_calib.size]
else:
displayed_calib = runtime.calib_envelope
xs_calib = resolve_curve_xs(displayed_calib.size)
curve_calib.setData(xs_calib, displayed_calib, autoDownsample=True)
else: else:
curve_calib.setData([], []) curve_calib.setData([], [])
@ -613,8 +736,8 @@ def run_pyqtgraph(args) -> None:
curve_norm.setData([], []) curve_norm.setData([], [])
if fixed_ylim is None: if fixed_ylim is None:
y_series = [runtime.current_sweep_raw, runtime.last_calib_sweep, runtime.current_sweep_norm] y_series = [runtime.current_sweep_raw, displayed_calib, runtime.current_sweep_norm]
if runtime.current_aux_curves is not None: if runtime.current_aux_curves is not None and runtime.current_sweep_raw is not None:
y_series.extend(runtime.current_aux_curves) y_series.extend(runtime.current_aux_curves)
y_limits = compute_auto_ylim(*y_series) y_limits = compute_auto_ylim(*y_series)
if y_limits is not None: if y_limits is not None:
@ -627,7 +750,7 @@ 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 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 distance_axis = runtime.current_distances if runtime.current_distances is not None else runtime.ring.distance_axis
if sweep_for_fft.size > 0 and distance_axis is not None: 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: 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) runtime.current_fft_db = compute_fft_row(sweep_for_fft, runtime.current_freqs, distance_axis.size)
fft_vals = runtime.current_fft_db fft_vals = runtime.current_fft_db
@ -735,16 +858,23 @@ def run_pyqtgraph(args) -> None:
else: else:
img.setImage(disp, autoLevels=False) img.setImage(disp, autoLevels=False)
if changed and runtime.current_info: if redraw_needed or status_dirty:
try: try:
status_payload = dict(runtime.current_info) status_payload = dict(runtime.current_info) if runtime.current_info else {}
if peak_calibrate_mode and runtime.current_peak_width is not None: if peak_calibrate_mode and runtime.current_peak_width is not None:
status_payload["peak_w"] = runtime.current_peak_width status_payload["peak_w"] = runtime.current_peak_width
if peak_calibrate_mode and runtime.current_peak_amplitude is not None: if peak_calibrate_mode and runtime.current_peak_amplitude is not None:
status_payload["peak_a"] = runtime.current_peak_amplitude status_payload["peak_a"] = runtime.current_peak_amplitude
status.setText(format_status_kv(status_payload)) base_status = format_status_kv(status_payload) if status_payload else ""
if status_note:
status.setText(f"{base_status} | {status_note}" if base_status else status_note)
else:
status.setText(base_status)
except Exception: except Exception:
pass pass
status_dirty = False
if changed and runtime.current_info:
try: try:
chs = runtime.current_info.get("chs") if isinstance(runtime.current_info, dict) else None chs = runtime.current_info.get("chs") if isinstance(runtime.current_info, dict) else None
if chs is None: if chs is None:

View File

@ -1,10 +1,13 @@
"""Pure sweep-processing helpers.""" """Pure sweep-processing helpers."""
from rfg_adc_plotter.processing.calibration import ( from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
calibrate_freqs, calibrate_freqs,
get_calibration_base, get_calibration_base,
get_calibration_coeffs, get_calibration_coeffs,
load_calib_envelope,
recalculate_calibration_c, recalculate_calibration_c,
save_calib_envelope,
set_calibration_base_value, set_calibration_base_value,
) )
from rfg_adc_plotter.processing.fft import ( from rfg_adc_plotter.processing.fft import (
@ -20,6 +23,7 @@ from rfg_adc_plotter.processing.formatting import (
) )
from rfg_adc_plotter.processing.normalization import ( from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes, build_calib_envelopes,
normalize_by_envelope,
normalize_by_calib, normalize_by_calib,
) )
from rfg_adc_plotter.processing.peaks import ( from rfg_adc_plotter.processing.peaks import (
@ -30,6 +34,7 @@ from rfg_adc_plotter.processing.peaks import (
__all__ = [ __all__ = [
"build_calib_envelopes", "build_calib_envelopes",
"build_calib_envelope",
"calibrate_freqs", "calibrate_freqs",
"compute_auto_ylim", "compute_auto_ylim",
"compute_distance_axis", "compute_distance_axis",
@ -41,9 +46,12 @@ __all__ = [
"format_status_kv", "format_status_kv",
"get_calibration_base", "get_calibration_base",
"get_calibration_coeffs", "get_calibration_coeffs",
"load_calib_envelope",
"normalize_by_envelope",
"normalize_by_calib", "normalize_by_calib",
"parse_spec_clip", "parse_spec_clip",
"recalculate_calibration_c", "recalculate_calibration_c",
"rolling_median_ref", "rolling_median_ref",
"save_calib_envelope",
"set_calibration_base_value", "set_calibration_base_value",
] ]

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping
import numpy as np import numpy as np
from rfg_adc_plotter.constants import SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ 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 from rfg_adc_plotter.types import SweepData
@ -79,3 +81,44 @@ def calibrate_freqs(sweep: Mapping[str, Any]) -> SweepData:
"F": freqs_cal, "F": freqs_cal,
"I": values_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

@ -108,6 +108,52 @@ def normalize_sweep_projector(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
return out 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)
finite_env = np.abs(env[np.isfinite(env)])
if finite_env.size > 0:
eps = max(float(np.median(finite_env)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = np.isfinite(raw_arr) & np.isfinite(env) & (np.abs(env) > eps)
if np.any(valid):
with np.errstate(divide="ignore", invalid="ignore"):
out[valid] = raw_arr[valid] / env[valid]
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: def normalize_by_calib(raw: np.ndarray, calib: np.ndarray, norm_type: str) -> np.ndarray:
"""Apply the selected normalization method.""" """Apply the selected normalization method."""
norm = str(norm_type).strip().lower() norm = str(norm_type).strip().lower()

View File

@ -21,6 +21,8 @@ class RuntimeState:
current_sweep_norm: Optional[np.ndarray] = None current_sweep_norm: Optional[np.ndarray] = None
current_fft_db: Optional[np.ndarray] = None current_fft_db: Optional[np.ndarray] = None
last_calib_sweep: Optional[np.ndarray] = None last_calib_sweep: Optional[np.ndarray] = None
calib_envelope: Optional[np.ndarray] = None
calib_file_path: Optional[str] = None
current_info: Optional[SweepInfo] = None current_info: Optional[SweepInfo] = None
bg_spec_cache: Optional[np.ndarray] = None bg_spec_cache: Optional[np.ndarray] = None
current_peak_width: Optional[float] = None current_peak_width: Optional[float] = None

View File

@ -1,11 +1,24 @@
from __future__ import annotations from __future__ import annotations
import os
import tempfile
import numpy as np import numpy as np
import unittest import unittest
from rfg_adc_plotter.processing.calibration import calibrate_freqs, recalculate_calibration_c 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.fft import compute_distance_axis, compute_fft_mag_row, compute_fft_row from rfg_adc_plotter.processing.fft import compute_distance_axis, compute_fft_mag_row, compute_fft_row
from rfg_adc_plotter.processing.normalization import build_calib_envelopes, normalize_by_calib 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 from rfg_adc_plotter.processing.peaks import find_peak_width_markers, find_top_peaks_over_ref, rolling_median_ref
@ -39,6 +52,32 @@ class ProcessingTests(unittest.TestCase):
self.assertTrue(np.any(np.isfinite(simple))) self.assertTrue(np.any(np.isfinite(simple)))
self.assertTrue(np.any(np.isfinite(projector))) 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_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_helpers_return_expected_shapes(self): def test_fft_helpers_return_expected_shapes(self):
sweep = np.sin(np.linspace(0.0, 4.0 * np.pi, 128)).astype(np.float32) 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) freqs = np.linspace(3.3, 14.3, 128, dtype=np.float64)