giga fix
This commit is contained in:
@ -42,6 +42,151 @@ RAW_PLOT_MAX_POINTS = 4096
|
|||||||
RAW_WATERFALL_MAX_POINTS = 2048
|
RAW_WATERFALL_MAX_POINTS = 2048
|
||||||
UI_MAX_PACKETS_PER_TICK = 8
|
UI_MAX_PACKETS_PER_TICK = 8
|
||||||
DEBUG_FRAME_LOG_EVERY = 10
|
DEBUG_FRAME_LOG_EVERY = 10
|
||||||
|
UI_BACKLOG_TAIL_THRESHOLD_MULTIPLIER = 2
|
||||||
|
UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER = 4
|
||||||
|
UI_HEAVY_REFRESH_BACKLOG_MULTIPLIER = 2
|
||||||
|
UI_HEAVY_REFRESH_MAX_STRIDE = 4
|
||||||
|
DEFAULT_MAIN_WINDOW_WIDTH = 1200
|
||||||
|
DEFAULT_MAIN_WINDOW_HEIGHT = 680
|
||||||
|
MIN_MAIN_WINDOW_WIDTH = 640
|
||||||
|
MIN_MAIN_WINDOW_HEIGHT = 420
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_curve_data_for_display(
|
||||||
|
xs: Optional[np.ndarray],
|
||||||
|
ys: Optional[np.ndarray],
|
||||||
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Drop non-finite points before passing a line series to pyqtgraph."""
|
||||||
|
if xs is None or ys is None:
|
||||||
|
return (
|
||||||
|
np.zeros((0,), dtype=np.float64),
|
||||||
|
np.zeros((0,), dtype=np.float32),
|
||||||
|
)
|
||||||
|
|
||||||
|
x_arr = np.asarray(xs, dtype=np.float64).reshape(-1)
|
||||||
|
y_arr = np.asarray(ys, dtype=np.float32).reshape(-1)
|
||||||
|
width = min(x_arr.size, y_arr.size)
|
||||||
|
if width <= 0:
|
||||||
|
return (
|
||||||
|
np.zeros((0,), dtype=np.float64),
|
||||||
|
np.zeros((0,), dtype=np.float32),
|
||||||
|
)
|
||||||
|
|
||||||
|
x_arr = x_arr[:width]
|
||||||
|
y_arr = y_arr[:width]
|
||||||
|
finite = np.isfinite(x_arr) & np.isfinite(y_arr)
|
||||||
|
if not np.any(finite):
|
||||||
|
return (
|
||||||
|
np.zeros((0,), dtype=np.float64),
|
||||||
|
np.zeros((0,), dtype=np.float32),
|
||||||
|
)
|
||||||
|
return x_arr[finite], y_arr[finite]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_image_for_display(data: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||||
|
"""Reject empty or fully non-finite images before calling ImageItem.setImage."""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
arr = np.asarray(data, dtype=np.float32)
|
||||||
|
if arr.ndim != 2 or arr.size <= 0 or not np.isfinite(arr).any():
|
||||||
|
return None
|
||||||
|
return arr
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_axis_bounds(
|
||||||
|
values: Optional[np.ndarray],
|
||||||
|
*,
|
||||||
|
default_span: float = 1.0,
|
||||||
|
) -> Optional[Tuple[float, float]]:
|
||||||
|
"""Return a finite plotting span for a 1D axis."""
|
||||||
|
if values is None:
|
||||||
|
return None
|
||||||
|
arr = np.asarray(values, dtype=np.float64).reshape(-1)
|
||||||
|
finite = arr[np.isfinite(arr)]
|
||||||
|
if finite.size <= 0:
|
||||||
|
return None
|
||||||
|
lower = float(np.min(finite))
|
||||||
|
upper = float(np.max(finite))
|
||||||
|
if upper <= lower:
|
||||||
|
upper = lower + max(float(default_span), 1e-9)
|
||||||
|
return (lower, upper)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_heavy_refresh_stride(
|
||||||
|
backlog_packets: int,
|
||||||
|
*,
|
||||||
|
max_packets: int = UI_MAX_PACKETS_PER_TICK,
|
||||||
|
) -> int:
|
||||||
|
"""Lower image refresh frequency when the UI is already behind."""
|
||||||
|
base = max(1, int(max_packets))
|
||||||
|
backlog = max(0, int(backlog_packets))
|
||||||
|
if backlog >= (base * UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER):
|
||||||
|
return UI_HEAVY_REFRESH_MAX_STRIDE
|
||||||
|
if backlog >= (base * UI_HEAVY_REFRESH_BACKLOG_MULTIPLIER):
|
||||||
|
return 2
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_initial_window_size(available_width: int, available_height: int) -> Tuple[int, int]:
|
||||||
|
"""Fit the initial window to the current screen without assuming desktop-sized geometry."""
|
||||||
|
width_in = int(max(0, available_width))
|
||||||
|
height_in = int(max(0, available_height))
|
||||||
|
if width_in <= 0 or height_in <= 0:
|
||||||
|
return DEFAULT_MAIN_WINDOW_WIDTH, DEFAULT_MAIN_WINDOW_HEIGHT
|
||||||
|
|
||||||
|
width_floor = min(MIN_MAIN_WINDOW_WIDTH, width_in)
|
||||||
|
height_floor = min(MIN_MAIN_WINDOW_HEIGHT, height_in)
|
||||||
|
width = int(width_in * 0.94)
|
||||||
|
height = int(height_in * 0.92)
|
||||||
|
width = min(max(width, width_floor), width_in)
|
||||||
|
height = min(max(height, height_floor), height_in)
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def build_main_window_layout(QtCore, QtWidgets, main_window):
|
||||||
|
"""Build a splitter-based main layout so settings stay usable on small screens."""
|
||||||
|
main_layout = QtWidgets.QVBoxLayout(main_window)
|
||||||
|
main_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
main_layout.setSpacing(6)
|
||||||
|
|
||||||
|
splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal, main_window)
|
||||||
|
try:
|
||||||
|
splitter.setChildrenCollapsible(False)
|
||||||
|
splitter.setHandleWidth(6)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
main_layout.addWidget(splitter)
|
||||||
|
|
||||||
|
plot_host = QtWidgets.QWidget()
|
||||||
|
plot_layout = QtWidgets.QVBoxLayout(plot_host)
|
||||||
|
plot_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
plot_layout.setSpacing(0)
|
||||||
|
splitter.addWidget(plot_host)
|
||||||
|
|
||||||
|
settings_widget = QtWidgets.QWidget()
|
||||||
|
settings_layout = QtWidgets.QVBoxLayout(settings_widget)
|
||||||
|
settings_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
settings_layout.setSpacing(8)
|
||||||
|
|
||||||
|
settings_scroll = QtWidgets.QScrollArea()
|
||||||
|
settings_scroll.setWidgetResizable(True)
|
||||||
|
settings_scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||||
|
settings_scroll.setWidget(settings_widget)
|
||||||
|
try:
|
||||||
|
settings_scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||||
|
settings_scroll.setMinimumWidth(250)
|
||||||
|
settings_scroll.setMaximumWidth(360)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
splitter.addWidget(settings_scroll)
|
||||||
|
|
||||||
|
try:
|
||||||
|
splitter.setStretchFactor(0, 1)
|
||||||
|
splitter.setStretchFactor(1, 0)
|
||||||
|
splitter.setSizes([900, 300])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return main_layout, splitter, plot_layout, settings_widget, settings_layout, settings_scroll
|
||||||
|
|
||||||
|
|
||||||
def _visible_levels_pyqtgraph(data: np.ndarray, plot_item) -> Optional[Tuple[float, float]]:
|
def _visible_levels_pyqtgraph(data: np.ndarray, plot_item) -> Optional[Tuple[float, float]]:
|
||||||
@ -247,10 +392,18 @@ def coalesce_packets_for_ui(
|
|||||||
packets: Sequence[SweepPacket],
|
packets: Sequence[SweepPacket],
|
||||||
*,
|
*,
|
||||||
max_packets: int = UI_MAX_PACKETS_PER_TICK,
|
max_packets: int = UI_MAX_PACKETS_PER_TICK,
|
||||||
|
backlog_packets: Optional[int] = None,
|
||||||
) -> Tuple[List[SweepPacket], int]:
|
) -> Tuple[List[SweepPacket], int]:
|
||||||
"""Keep only the newest packets so a burst cannot starve the Qt event loop."""
|
"""Keep only the newest packets so a burst cannot starve the Qt event loop."""
|
||||||
packet_list = list(packets)
|
packet_list = list(packets)
|
||||||
limit = max(1, int(max_packets))
|
base_limit = max(1, int(max_packets))
|
||||||
|
backlog = max(len(packet_list), int(backlog_packets or 0))
|
||||||
|
if backlog >= (base_limit * UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER):
|
||||||
|
limit = 1
|
||||||
|
elif backlog >= (base_limit * UI_BACKLOG_TAIL_THRESHOLD_MULTIPLIER):
|
||||||
|
limit = min(2, base_limit)
|
||||||
|
else:
|
||||||
|
limit = base_limit
|
||||||
if len(packet_list) <= limit:
|
if len(packet_list) <= limit:
|
||||||
return packet_list, 0
|
return packet_list, 0
|
||||||
skipped = len(packet_list) - limit
|
skipped = len(packet_list) - limit
|
||||||
@ -381,23 +534,16 @@ def run_pyqtgraph(args) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
main_layout = QtWidgets.QHBoxLayout(main_window)
|
|
||||||
main_layout.setContentsMargins(6, 6, 6, 6)
|
|
||||||
main_layout.setSpacing(6)
|
|
||||||
|
|
||||||
win = pg.GraphicsLayoutWidget(show=False, title=args.title)
|
win = pg.GraphicsLayoutWidget(show=False, title=args.title)
|
||||||
main_layout.addWidget(win)
|
(
|
||||||
|
_main_layout,
|
||||||
settings_widget = QtWidgets.QWidget()
|
splitter,
|
||||||
settings_layout = QtWidgets.QVBoxLayout(settings_widget)
|
plot_layout,
|
||||||
settings_layout.setContentsMargins(6, 6, 6, 6)
|
settings_widget,
|
||||||
settings_layout.setSpacing(8)
|
settings_layout,
|
||||||
try:
|
settings_scroll,
|
||||||
settings_widget.setMinimumWidth(250)
|
) = build_main_window_layout(QtCore, QtWidgets, main_window)
|
||||||
settings_widget.setMaximumWidth(340)
|
plot_layout.addWidget(win)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
main_layout.addWidget(settings_widget)
|
|
||||||
|
|
||||||
p_line = win.addPlot(row=0, col=0, title="Сырые данные")
|
p_line = win.addPlot(row=0, col=0, title="Сырые данные")
|
||||||
p_line.showGrid(x=True, y=True, alpha=0.3)
|
p_line.showGrid(x=True, y=True, alpha=0.3)
|
||||||
@ -597,8 +743,13 @@ def run_pyqtgraph(args) -> None:
|
|||||||
fft_imag_enabled = True
|
fft_imag_enabled = True
|
||||||
fft_mode = "symmetric"
|
fft_mode = "symmetric"
|
||||||
status_note = ""
|
status_note = ""
|
||||||
|
status_note_expires_at: Optional[float] = None
|
||||||
status_dirty = True
|
status_dirty = True
|
||||||
range_change_in_progress = False
|
range_change_in_progress = False
|
||||||
|
debug_event_counts: Dict[str, int] = {}
|
||||||
|
last_queue_backlog = 0
|
||||||
|
last_backlog_skipped = 0
|
||||||
|
last_heavy_refresh_stride = 1
|
||||||
fixed_ylim: Optional[Tuple[float, float]] = None
|
fixed_ylim: Optional[Tuple[float, float]] = None
|
||||||
if args.ylim:
|
if args.ylim:
|
||||||
try:
|
try:
|
||||||
@ -615,50 +766,37 @@ def run_pyqtgraph(args) -> None:
|
|||||||
return
|
return
|
||||||
f_min = float(runtime.range_min_ghz)
|
f_min = float(runtime.range_min_ghz)
|
||||||
f_max = float(runtime.range_max_ghz)
|
f_max = float(runtime.range_max_ghz)
|
||||||
if runtime.current_freqs is not None and runtime.current_freqs.size > 0:
|
freq_bounds = resolve_axis_bounds(runtime.current_freqs)
|
||||||
finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)]
|
if freq_bounds is not None:
|
||||||
if finite_f.size > 0:
|
f_min, f_max = freq_bounds
|
||||||
f_min = float(np.min(finite_f))
|
disp_raw = sanitize_image_for_display(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS))
|
||||||
f_max = float(np.max(finite_f))
|
if disp_raw is not None:
|
||||||
img.setImage(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS), autoLevels=False)
|
img.setImage(disp_raw, autoLevels=False)
|
||||||
img.setRect(0, f_min, max_sweeps, max(1e-9, f_max - f_min))
|
img.setRect(0, f_min, max_sweeps, max(1e-9, f_max - f_min))
|
||||||
p_img.setRange(
|
p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0)
|
||||||
xRange=(0, max_sweeps - 1),
|
|
||||||
yRange=(f_min, f_max),
|
|
||||||
padding=0,
|
|
||||||
)
|
|
||||||
p_line.setXRange(f_min, f_max, padding=0)
|
p_line.setXRange(f_min, f_max, padding=0)
|
||||||
img_fft.setImage(runtime.ring.get_display_fft_linear(), autoLevels=False)
|
disp_fft = sanitize_image_for_display(runtime.ring.get_display_fft_linear())
|
||||||
|
if disp_fft is not None:
|
||||||
|
img_fft.setImage(disp_fft, autoLevels=False)
|
||||||
img_fft.setRect(0, 0.0, max_sweeps, 1.0)
|
img_fft.setRect(0, 0.0, max_sweeps, 1.0)
|
||||||
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(0.0, 1.0), padding=0)
|
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(0.0, 1.0), padding=0)
|
||||||
p_fft.setXRange(0.0, 1.0, padding=0)
|
p_fft.setXRange(0.0, 1.0, padding=0)
|
||||||
|
|
||||||
def update_physical_axes() -> None:
|
def update_physical_axes() -> None:
|
||||||
if runtime.current_freqs is not None and runtime.current_freqs.size > 0:
|
freq_bounds = resolve_axis_bounds(runtime.current_freqs)
|
||||||
finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)]
|
if freq_bounds is None:
|
||||||
if finite_f.size > 0:
|
freq_bounds = resolve_axis_bounds(
|
||||||
f_min = float(np.min(finite_f))
|
np.asarray([runtime.range_min_ghz, runtime.range_max_ghz], dtype=np.float64)
|
||||||
f_max = float(np.max(finite_f))
|
)
|
||||||
if f_max <= f_min:
|
if freq_bounds is not None:
|
||||||
f_max = f_min + 1.0
|
f_min, f_max = freq_bounds
|
||||||
img.setRect(0, f_min, max_sweeps, f_max - f_min)
|
|
||||||
p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0)
|
|
||||||
p_line.setXRange(f_min, f_max, padding=0)
|
|
||||||
else:
|
|
||||||
f_min = float(runtime.range_min_ghz)
|
|
||||||
f_max = float(runtime.range_max_ghz)
|
|
||||||
if f_max <= f_min:
|
|
||||||
f_max = f_min + 1.0
|
|
||||||
img.setRect(0, f_min, max_sweeps, f_max - f_min)
|
img.setRect(0, f_min, max_sweeps, f_max - f_min)
|
||||||
p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0)
|
p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0)
|
||||||
p_line.setXRange(f_min, f_max, padding=0)
|
p_line.setXRange(f_min, f_max, padding=0)
|
||||||
|
|
||||||
distance_axis = runtime.ring.distance_axis
|
distance_bounds = resolve_axis_bounds(runtime.ring.distance_axis)
|
||||||
if distance_axis is not None and distance_axis.size > 0:
|
if distance_bounds is not None:
|
||||||
d_min = float(distance_axis[0])
|
d_min, d_max = distance_bounds
|
||||||
d_max = float(distance_axis[-1]) if distance_axis.size > 1 else float(distance_axis[0] + 1.0)
|
|
||||||
if d_max <= d_min:
|
|
||||||
d_max = d_min + 1.0
|
|
||||||
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)
|
||||||
|
|
||||||
@ -685,11 +823,28 @@ def run_pyqtgraph(args) -> None:
|
|||||||
return runtime.ring.x_shared[:size]
|
return runtime.ring.x_shared[:size]
|
||||||
return np.arange(size, dtype=np.float32)
|
return np.arange(size, dtype=np.float32)
|
||||||
|
|
||||||
def set_status_note(note: str) -> None:
|
def set_status_note(note: str, ttl_s: Optional[float] = None) -> None:
|
||||||
nonlocal status_note, status_dirty
|
nonlocal status_note, status_note_expires_at, status_dirty
|
||||||
status_note = str(note).strip()
|
status_note = str(note).strip()
|
||||||
|
status_note_expires_at = None if ttl_s is None else (time.time() + max(0.1, float(ttl_s)))
|
||||||
status_dirty = True
|
status_dirty = True
|
||||||
|
|
||||||
|
def clear_expired_status_note() -> None:
|
||||||
|
nonlocal status_note, status_note_expires_at, status_dirty
|
||||||
|
if status_note_expires_at is None:
|
||||||
|
return
|
||||||
|
if time.time() < status_note_expires_at:
|
||||||
|
return
|
||||||
|
status_note = ""
|
||||||
|
status_note_expires_at = None
|
||||||
|
status_dirty = True
|
||||||
|
|
||||||
|
def log_debug_event(name: str, message: str, *, every: int = DEBUG_FRAME_LOG_EVERY) -> None:
|
||||||
|
count = int(debug_event_counts.get(name, 0)) + 1
|
||||||
|
debug_event_counts[name] = count
|
||||||
|
if count == 1 or count % max(1, int(every)) == 0:
|
||||||
|
sys.stderr.write(f"[debug] {message} (x{count})\n")
|
||||||
|
|
||||||
def get_calib_file_path() -> str:
|
def get_calib_file_path() -> str:
|
||||||
try:
|
try:
|
||||||
path = calib_path_edit.text().strip()
|
path = calib_path_edit.text().strip()
|
||||||
@ -836,7 +991,13 @@ def run_pyqtgraph(args) -> None:
|
|||||||
runtime.current_fft_complex = None
|
runtime.current_fft_complex = None
|
||||||
runtime.current_fft_mag = runtime.ring.get_last_fft_linear()
|
runtime.current_fft_mag = runtime.ring.get_last_fft_linear()
|
||||||
runtime.current_fft_db = runtime.ring.last_fft_db
|
runtime.current_fft_db = runtime.ring.last_fft_db
|
||||||
if runtime.current_fft_mag is not None:
|
if not runtime.ring.last_push_fft_valid:
|
||||||
|
set_status_note("fft: пропущен невалидный кадр", ttl_s=2.0)
|
||||||
|
log_debug_event("invalid_fft", "ui invalid FFT row suppressed")
|
||||||
|
if not runtime.ring.last_push_axis_valid:
|
||||||
|
set_status_note("fft: ось оставлена по последнему валидному кадру", ttl_s=2.0)
|
||||||
|
log_debug_event("invalid_fft_axis", "ui invalid FFT axis suppressed")
|
||||||
|
if runtime.ring.last_push_fft_valid and runtime.current_fft_mag is not None:
|
||||||
runtime.background_buffer.push(runtime.current_fft_mag)
|
runtime.background_buffer.push(runtime.current_fft_mag)
|
||||||
|
|
||||||
def set_calib_enabled() -> None:
|
def set_calib_enabled() -> None:
|
||||||
@ -1261,19 +1422,26 @@ def run_pyqtgraph(args) -> None:
|
|||||||
processed_frames = 0
|
processed_frames = 0
|
||||||
ui_frames_skipped = 0
|
ui_frames_skipped = 0
|
||||||
ui_started_at = time.perf_counter()
|
ui_started_at = time.perf_counter()
|
||||||
|
update_ticks = 0
|
||||||
|
|
||||||
def refresh_current_fft_cache(sweep_for_fft: np.ndarray, bins: int) -> None:
|
def refresh_current_fft_cache(sweep_for_fft: np.ndarray, bins: int) -> None:
|
||||||
runtime.current_fft_complex = compute_fft_complex_row(
|
fft_complex = compute_fft_complex_row(
|
||||||
sweep_for_fft,
|
sweep_for_fft,
|
||||||
runtime.current_freqs,
|
runtime.current_freqs,
|
||||||
bins,
|
bins,
|
||||||
mode=fft_mode,
|
mode=fft_mode,
|
||||||
)
|
)
|
||||||
runtime.current_fft_mag = np.abs(runtime.current_fft_complex).astype(np.float32, copy=False)
|
fft_mag = np.abs(fft_complex).astype(np.float32, copy=False)
|
||||||
|
if not np.isfinite(fft_mag).any():
|
||||||
|
set_status_note("fft: локальный пересчет пропущен", ttl_s=2.0)
|
||||||
|
log_debug_event("invalid_fft_cache", "ui invalid FFT cache suppressed")
|
||||||
|
return
|
||||||
|
runtime.current_fft_complex = fft_complex
|
||||||
|
runtime.current_fft_mag = fft_mag
|
||||||
runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag)
|
runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag)
|
||||||
|
|
||||||
def drain_queue() -> int:
|
def drain_queue() -> int:
|
||||||
nonlocal processed_frames, ui_frames_skipped
|
nonlocal processed_frames, ui_frames_skipped, last_queue_backlog, last_backlog_skipped, last_heavy_refresh_stride
|
||||||
pending_packets: List[SweepPacket] = []
|
pending_packets: List[SweepPacket] = []
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@ -1281,12 +1449,31 @@ def run_pyqtgraph(args) -> None:
|
|||||||
except Empty:
|
except Empty:
|
||||||
break
|
break
|
||||||
drained = len(pending_packets)
|
drained = len(pending_packets)
|
||||||
|
last_queue_backlog = drained
|
||||||
if drained <= 0:
|
if drained <= 0:
|
||||||
|
last_backlog_skipped = 0
|
||||||
|
last_heavy_refresh_stride = 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
pending_packets, skipped_packets = coalesce_packets_for_ui(pending_packets)
|
pending_packets, skipped_packets = coalesce_packets_for_ui(
|
||||||
|
pending_packets,
|
||||||
|
backlog_packets=drained,
|
||||||
|
)
|
||||||
|
last_backlog_skipped = skipped_packets
|
||||||
|
last_heavy_refresh_stride = resolve_heavy_refresh_stride(drained)
|
||||||
ui_frames_skipped += skipped_packets
|
ui_frames_skipped += skipped_packets
|
||||||
|
if skipped_packets > 0:
|
||||||
|
log_debug_event(
|
||||||
|
"ui_backlog",
|
||||||
|
f"ui backlog:{drained} keep:{len(pending_packets)} skipped:{skipped_packets}",
|
||||||
|
)
|
||||||
for sweep, info, aux_curves in pending_packets:
|
for sweep, info, aux_curves in pending_packets:
|
||||||
|
if runtime.ring.width > 0 and sweep.size < max(8, runtime.ring.width // 2):
|
||||||
|
set_status_note(f"короткий свип: {int(sweep.size)} точек", ttl_s=2.0)
|
||||||
|
log_debug_event(
|
||||||
|
"short_sweep",
|
||||||
|
f"ui short sweep width:{int(sweep.size)} expected:{int(runtime.ring.width)}",
|
||||||
|
)
|
||||||
base_freqs = np.linspace(SWEEP_FREQ_MIN_GHZ, SWEEP_FREQ_MAX_GHZ, sweep.size, dtype=np.float64)
|
base_freqs = np.linspace(SWEEP_FREQ_MIN_GHZ, SWEEP_FREQ_MAX_GHZ, sweep.size, dtype=np.float64)
|
||||||
runtime.full_current_aux_curves = None
|
runtime.full_current_aux_curves = None
|
||||||
runtime.full_current_fft_source = None
|
runtime.full_current_fft_source = None
|
||||||
@ -1364,15 +1551,22 @@ def run_pyqtgraph(args) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def update() -> None:
|
def update() -> None:
|
||||||
nonlocal peak_ref_window, status_dirty
|
nonlocal peak_ref_window, status_dirty, update_ticks
|
||||||
norm_display_scale = 500.0
|
norm_display_scale = 500.0
|
||||||
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():
|
||||||
return
|
return
|
||||||
|
update_ticks += 1
|
||||||
|
clear_expired_status_note()
|
||||||
|
|
||||||
changed = drain_queue() > 0
|
changed = drain_queue() > 0
|
||||||
redraw_needed = changed or runtime.plot_dirty
|
redraw_needed = changed or runtime.plot_dirty
|
||||||
|
refresh_heavy_views = (
|
||||||
|
runtime.plot_dirty
|
||||||
|
or last_heavy_refresh_stride <= 1
|
||||||
|
or (update_ticks % last_heavy_refresh_stride) == 0
|
||||||
|
)
|
||||||
|
|
||||||
if redraw_needed:
|
if redraw_needed:
|
||||||
xs = resolve_curve_xs(
|
xs = resolve_curve_xs(
|
||||||
@ -1385,6 +1579,7 @@ def run_pyqtgraph(args) -> None:
|
|||||||
|
|
||||||
if runtime.current_sweep_raw is not None:
|
if runtime.current_sweep_raw is not None:
|
||||||
raw_x, raw_y = decimate_curve_for_display(xs, runtime.current_sweep_raw)
|
raw_x, raw_y = decimate_curve_for_display(xs, runtime.current_sweep_raw)
|
||||||
|
raw_x, raw_y = sanitize_curve_data_for_display(raw_x, raw_y)
|
||||||
curve.setData(raw_x, raw_y, autoDownsample=False)
|
curve.setData(raw_x, raw_y, autoDownsample=False)
|
||||||
else:
|
else:
|
||||||
curve.setData([], [])
|
curve.setData([], [])
|
||||||
@ -1394,6 +1589,8 @@ def run_pyqtgraph(args) -> None:
|
|||||||
aux_width = min(xs.size, aux_1.size, aux_2.size)
|
aux_width = min(xs.size, aux_1.size, aux_2.size)
|
||||||
aux_x_1, aux_y_1 = decimate_curve_for_display(xs[:aux_width], aux_1[:aux_width])
|
aux_x_1, aux_y_1 = decimate_curve_for_display(xs[:aux_width], aux_1[:aux_width])
|
||||||
aux_x_2, aux_y_2 = decimate_curve_for_display(xs[:aux_width], aux_2[:aux_width])
|
aux_x_2, aux_y_2 = decimate_curve_for_display(xs[:aux_width], aux_2[:aux_width])
|
||||||
|
aux_x_1, aux_y_1 = sanitize_curve_data_for_display(aux_x_1, aux_y_1)
|
||||||
|
aux_x_2, aux_y_2 = sanitize_curve_data_for_display(aux_x_2, aux_y_2)
|
||||||
curve_aux_1.setData(aux_x_1, aux_y_1, autoDownsample=False)
|
curve_aux_1.setData(aux_x_1, aux_y_1, autoDownsample=False)
|
||||||
curve_aux_2.setData(aux_x_2, aux_y_2, autoDownsample=False)
|
curve_aux_2.setData(aux_x_2, aux_y_2, autoDownsample=False)
|
||||||
else:
|
else:
|
||||||
@ -1408,6 +1605,7 @@ def run_pyqtgraph(args) -> None:
|
|||||||
displayed_calib = runtime.calib_envelope
|
displayed_calib = runtime.calib_envelope
|
||||||
xs_calib = resolve_curve_xs(displayed_calib.size)
|
xs_calib = resolve_curve_xs(displayed_calib.size)
|
||||||
calib_x, calib_y = decimate_curve_for_display(xs_calib, displayed_calib)
|
calib_x, calib_y = decimate_curve_for_display(xs_calib, displayed_calib)
|
||||||
|
calib_x, calib_y = sanitize_curve_data_for_display(calib_x, calib_y)
|
||||||
curve_calib.setData(calib_x, calib_y, autoDownsample=False)
|
curve_calib.setData(calib_x, calib_y, autoDownsample=False)
|
||||||
else:
|
else:
|
||||||
curve_calib.setData([], [])
|
curve_calib.setData([], [])
|
||||||
@ -1415,6 +1613,7 @@ def run_pyqtgraph(args) -> None:
|
|||||||
if runtime.current_sweep_norm is not None:
|
if runtime.current_sweep_norm is not None:
|
||||||
norm_display = runtime.current_sweep_norm * norm_display_scale
|
norm_display = runtime.current_sweep_norm * norm_display_scale
|
||||||
norm_x, norm_y = decimate_curve_for_display(xs[: norm_display.size], norm_display)
|
norm_x, norm_y = decimate_curve_for_display(xs[: norm_display.size], norm_display)
|
||||||
|
norm_x, norm_y = sanitize_curve_data_for_display(norm_x, norm_y)
|
||||||
curve_norm.setData(norm_x, norm_y, autoDownsample=False)
|
curve_norm.setData(norm_x, norm_y, autoDownsample=False)
|
||||||
else:
|
else:
|
||||||
curve_norm.setData([], [])
|
curve_norm.setData([], [])
|
||||||
@ -1431,10 +1630,9 @@ def run_pyqtgraph(args) -> None:
|
|||||||
if y_limits is not None:
|
if y_limits is not None:
|
||||||
p_line.setYRange(y_limits[0], y_limits[1], padding=0)
|
p_line.setYRange(y_limits[0], y_limits[1], padding=0)
|
||||||
|
|
||||||
if isinstance(xs, np.ndarray) and xs.size > 0:
|
line_x_bounds = resolve_axis_bounds(xs)
|
||||||
finite_x = xs[np.isfinite(xs)]
|
if line_x_bounds is not None:
|
||||||
if finite_x.size > 0:
|
p_line.setXRange(line_x_bounds[0], line_x_bounds[1], padding=0)
|
||||||
p_line.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0)
|
|
||||||
|
|
||||||
sweep_for_fft = runtime.current_fft_input
|
sweep_for_fft = runtime.current_fft_input
|
||||||
if sweep_for_fft is None:
|
if sweep_for_fft is None:
|
||||||
@ -1470,9 +1668,9 @@ def run_pyqtgraph(args) -> None:
|
|||||||
set_status_note(f"фон: не удалось применить ({exc})")
|
set_status_note(f"фон: не удалось применить ({exc})")
|
||||||
active_background = None
|
active_background = None
|
||||||
display_fft_mag = fft_mag
|
display_fft_mag = fft_mag
|
||||||
finite_x = xs_fft[np.isfinite(xs_fft)]
|
fft_x_bounds = resolve_axis_bounds(xs_fft)
|
||||||
if finite_x.size > 0:
|
if fft_x_bounds is not None:
|
||||||
p_fft.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0)
|
p_fft.setXRange(fft_x_bounds[0], fft_x_bounds[1], padding=0)
|
||||||
|
|
||||||
fft_vals_db = fft_mag_to_db(display_fft_mag)
|
fft_vals_db = fft_mag_to_db(display_fft_mag)
|
||||||
ref_curve_for_range = None
|
ref_curve_for_range = None
|
||||||
@ -1487,15 +1685,18 @@ def run_pyqtgraph(args) -> None:
|
|||||||
show_imag=fft_imag_enabled,
|
show_imag=fft_imag_enabled,
|
||||||
)
|
)
|
||||||
if visible_abs is not None:
|
if visible_abs is not None:
|
||||||
curve_fft.setData(xs_fft[: visible_abs.size], visible_abs)
|
abs_x, abs_y = sanitize_curve_data_for_display(xs_fft[: visible_abs.size], visible_abs)
|
||||||
|
curve_fft.setData(abs_x, abs_y)
|
||||||
else:
|
else:
|
||||||
curve_fft.setData([], [])
|
curve_fft.setData([], [])
|
||||||
if visible_real is not None:
|
if visible_real is not None:
|
||||||
curve_fft_real.setData(xs_fft[: visible_real.size], visible_real)
|
real_x, real_y = sanitize_curve_data_for_display(xs_fft[: visible_real.size], visible_real)
|
||||||
|
curve_fft_real.setData(real_x, real_y)
|
||||||
else:
|
else:
|
||||||
curve_fft_real.setData([], [])
|
curve_fft_real.setData([], [])
|
||||||
if visible_imag is not None:
|
if visible_imag is not None:
|
||||||
curve_fft_imag.setData(xs_fft[: visible_imag.size], visible_imag)
|
imag_x, imag_y = sanitize_curve_data_for_display(xs_fft[: visible_imag.size], visible_imag)
|
||||||
|
curve_fft_imag.setData(imag_x, imag_y)
|
||||||
else:
|
else:
|
||||||
curve_fft_imag.setData([], [])
|
curve_fft_imag.setData([], [])
|
||||||
|
|
||||||
@ -1504,7 +1705,8 @@ def run_pyqtgraph(args) -> None:
|
|||||||
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref_db)
|
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref_db)
|
||||||
if np.any(finite_ref):
|
if np.any(finite_ref):
|
||||||
fft_ref_lin = _db_to_linear_amplitude(fft_ref_db[finite_ref])
|
fft_ref_lin = _db_to_linear_amplitude(fft_ref_db[finite_ref])
|
||||||
curve_fft_ref.setData(xs_fft[finite_ref], fft_ref_lin)
|
ref_x, ref_y = sanitize_curve_data_for_display(xs_fft[finite_ref], fft_ref_lin)
|
||||||
|
curve_fft_ref.setData(ref_x, ref_y)
|
||||||
curve_fft_ref.setVisible(True)
|
curve_fft_ref.setVisible(True)
|
||||||
ref_curve_for_range = fft_ref_lin
|
ref_curve_for_range = fft_ref_lin
|
||||||
else:
|
else:
|
||||||
@ -1573,7 +1775,8 @@ def run_pyqtgraph(args) -> None:
|
|||||||
curve_fft_real.setData([], [])
|
curve_fft_real.setData([], [])
|
||||||
curve_fft_imag.setData([], [])
|
curve_fft_imag.setData([], [])
|
||||||
if fft_abs_enabled:
|
if fft_abs_enabled:
|
||||||
curve_fft.setData(xs_fft, fft_vals_db)
|
fft_x, fft_y = sanitize_curve_data_for_display(xs_fft, fft_vals_db)
|
||||||
|
curve_fft.setData(fft_x, fft_y)
|
||||||
else:
|
else:
|
||||||
curve_fft.setData([], [])
|
curve_fft.setData([], [])
|
||||||
|
|
||||||
@ -1583,7 +1786,8 @@ def run_pyqtgraph(args) -> None:
|
|||||||
fft_ref = rolling_median_ref(xs_fft, fft_vals_db, peak_ref_window)
|
fft_ref = rolling_median_ref(xs_fft, fft_vals_db, peak_ref_window)
|
||||||
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref)
|
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref)
|
||||||
if np.any(finite_ref):
|
if np.any(finite_ref):
|
||||||
curve_fft_ref.setData(xs_fft[finite_ref], fft_ref[finite_ref])
|
ref_x, ref_y = sanitize_curve_data_for_display(xs_fft[finite_ref], fft_ref[finite_ref])
|
||||||
|
curve_fft_ref.setData(ref_x, ref_y)
|
||||||
curve_fft_ref.setVisible(True)
|
curve_fft_ref.setVisible(True)
|
||||||
y_for_range = np.concatenate((y_for_range, fft_ref[finite_ref]))
|
y_for_range = np.concatenate((y_for_range, fft_ref[finite_ref]))
|
||||||
else:
|
else:
|
||||||
@ -1668,12 +1872,22 @@ def run_pyqtgraph(args) -> None:
|
|||||||
runtime.plot_dirty = False
|
runtime.plot_dirty = False
|
||||||
|
|
||||||
if changed and runtime.ring.ring is not None:
|
if changed and runtime.ring.ring is not None:
|
||||||
disp = runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS)
|
if refresh_heavy_views:
|
||||||
|
disp = sanitize_image_for_display(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS))
|
||||||
|
if disp is not None:
|
||||||
levels = _visible_levels_pyqtgraph(disp, p_img)
|
levels = _visible_levels_pyqtgraph(disp, p_img)
|
||||||
if levels is not None:
|
if levels is not None:
|
||||||
img.setImage(disp, autoLevels=False, levels=levels)
|
img.setImage(disp, autoLevels=False, levels=levels)
|
||||||
else:
|
else:
|
||||||
img.setImage(disp, autoLevels=False)
|
img.setImage(disp, autoLevels=False)
|
||||||
|
else:
|
||||||
|
set_status_note("raw waterfall: кадр пропущен", ttl_s=2.0)
|
||||||
|
log_debug_event("invalid_raw_image", "ui invalid raw waterfall suppressed")
|
||||||
|
else:
|
||||||
|
log_debug_event(
|
||||||
|
"suppressed_image_refresh",
|
||||||
|
f"ui waterfall refresh suppressed stride:{last_heavy_refresh_stride}",
|
||||||
|
)
|
||||||
|
|
||||||
if redraw_needed or status_dirty:
|
if redraw_needed or status_dirty:
|
||||||
try:
|
try:
|
||||||
@ -1683,8 +1897,16 @@ def run_pyqtgraph(args) -> None:
|
|||||||
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
|
||||||
base_status = format_status_kv(status_payload) if status_payload else ""
|
base_status = format_status_kv(status_payload) if status_payload else ""
|
||||||
|
status_parts = []
|
||||||
if status_note:
|
if status_note:
|
||||||
status.setText(f"{base_status} | {status_note}" if base_status else status_note)
|
status_parts.append(status_note)
|
||||||
|
if last_backlog_skipped > 0:
|
||||||
|
status_parts.append(f"ui backlog:{last_queue_backlog} skip:{last_backlog_skipped}")
|
||||||
|
if last_heavy_refresh_stride > 1:
|
||||||
|
status_parts.append(f"ui wf/{last_heavy_refresh_stride}")
|
||||||
|
status_suffix = " | ".join(status_parts)
|
||||||
|
if status_suffix:
|
||||||
|
status.setText(f"{base_status} | {status_suffix}" if base_status else status_suffix)
|
||||||
else:
|
else:
|
||||||
status.setText(base_status)
|
status.setText(base_status)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -1713,6 +1935,12 @@ def run_pyqtgraph(args) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if redraw_needed and runtime.ring.ring_fft is not None:
|
if redraw_needed and runtime.ring.ring_fft is not None:
|
||||||
|
if not refresh_heavy_views:
|
||||||
|
log_debug_event(
|
||||||
|
"suppressed_fft_image_refresh",
|
||||||
|
f"ui FFT waterfall refresh suppressed stride:{last_heavy_refresh_stride}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
disp_fft_lin = runtime.ring.get_display_fft_linear()
|
disp_fft_lin = runtime.ring.get_display_fft_linear()
|
||||||
if spec_mean_sec > 0.0:
|
if spec_mean_sec > 0.0:
|
||||||
disp_times = runtime.ring.get_display_times()
|
disp_times = runtime.ring.get_display_times()
|
||||||
@ -1739,6 +1967,11 @@ def run_pyqtgraph(args) -> None:
|
|||||||
set_status_note(f"фон: не удалось применить ({exc})")
|
set_status_note(f"фон: не удалось применить ({exc})")
|
||||||
active_background = None
|
active_background = None
|
||||||
disp_fft = fft_mag_to_db(disp_fft_lin)
|
disp_fft = fft_mag_to_db(disp_fft_lin)
|
||||||
|
disp_fft = sanitize_image_for_display(disp_fft)
|
||||||
|
if disp_fft is None:
|
||||||
|
set_status_note("b-scan: кадр пропущен", ttl_s=2.0)
|
||||||
|
log_debug_event("invalid_fft_image", "ui invalid FFT waterfall suppressed")
|
||||||
|
return
|
||||||
|
|
||||||
levels = None
|
levels = None
|
||||||
if active_background is not None:
|
if active_background is not None:
|
||||||
@ -1842,7 +2075,16 @@ def run_pyqtgraph(args) -> None:
|
|||||||
|
|
||||||
app.aboutToQuit.connect(on_quit)
|
app.aboutToQuit.connect(on_quit)
|
||||||
try:
|
try:
|
||||||
main_window.resize(1200, 680)
|
available = None
|
||||||
|
screen = app.primaryScreen()
|
||||||
|
if screen is not None:
|
||||||
|
available = screen.availableGeometry()
|
||||||
|
if available is not None:
|
||||||
|
width, height = resolve_initial_window_size(available.width(), available.height())
|
||||||
|
else:
|
||||||
|
width, height = DEFAULT_MAIN_WINDOW_WIDTH, DEFAULT_MAIN_WINDOW_HEIGHT
|
||||||
|
main_window.resize(width, height)
|
||||||
|
splitter.setSizes([max(int(width * 0.68), width - 320), min(320, max(240, int(width * 0.32)))])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
main_window.show()
|
main_window.show()
|
||||||
|
|||||||
@ -31,6 +31,9 @@ class RingBuffer:
|
|||||||
self.last_freqs: Optional[np.ndarray] = None
|
self.last_freqs: Optional[np.ndarray] = None
|
||||||
self.y_min_fft: Optional[float] = None
|
self.y_min_fft: Optional[float] = None
|
||||||
self.y_max_fft: Optional[float] = None
|
self.y_max_fft: Optional[float] = None
|
||||||
|
self.last_push_valid_points = 0
|
||||||
|
self.last_push_fft_valid = False
|
||||||
|
self.last_push_axis_valid = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
@ -55,6 +58,38 @@ class RingBuffer:
|
|||||||
self.last_freqs = None
|
self.last_freqs = None
|
||||||
self.y_min_fft = None
|
self.y_min_fft = None
|
||||||
self.y_max_fft = None
|
self.y_max_fft = None
|
||||||
|
self.last_push_valid_points = 0
|
||||||
|
self.last_push_fft_valid = False
|
||||||
|
self.last_push_axis_valid = False
|
||||||
|
|
||||||
|
def _promote_fft_cache(self, fft_mag: np.ndarray) -> bool:
|
||||||
|
fft_mag_arr = np.asarray(fft_mag, dtype=np.float32).reshape(-1)
|
||||||
|
if fft_mag_arr.size <= 0:
|
||||||
|
self.last_push_fft_valid = False
|
||||||
|
return False
|
||||||
|
fft_db = fft_mag_to_db(fft_mag_arr)
|
||||||
|
finite_db = fft_db[np.isfinite(fft_db)]
|
||||||
|
if finite_db.size <= 0:
|
||||||
|
self.last_push_fft_valid = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.last_fft_mag = fft_mag_arr.copy()
|
||||||
|
self.last_fft_db = fft_db
|
||||||
|
fr_min = float(np.min(finite_db))
|
||||||
|
fr_max = float(np.max(finite_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)
|
||||||
|
self.last_push_fft_valid = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _promote_distance_axis(self, axis: np.ndarray) -> bool:
|
||||||
|
axis_arr = np.asarray(axis, dtype=np.float64).reshape(-1)
|
||||||
|
if axis_arr.size <= 0 or not np.all(np.isfinite(axis_arr)):
|
||||||
|
self.last_push_axis_valid = False
|
||||||
|
return False
|
||||||
|
self.distance_axis = axis_arr.copy()
|
||||||
|
self.last_push_axis_valid = True
|
||||||
|
return True
|
||||||
|
|
||||||
def ensure_init(self, sweep_width: int) -> bool:
|
def ensure_init(self, sweep_width: int) -> bool:
|
||||||
"""Allocate or resize buffers. Returns True when geometry changed."""
|
"""Allocate or resize buffers. Returns True when geometry changed."""
|
||||||
@ -107,6 +142,8 @@ class RingBuffer:
|
|||||||
self.fft_mode = normalized_mode
|
self.fft_mode = normalized_mode
|
||||||
self.y_min_fft = None
|
self.y_min_fft = None
|
||||||
self.y_max_fft = None
|
self.y_max_fft = None
|
||||||
|
self.last_push_fft_valid = False
|
||||||
|
self.last_push_axis_valid = False
|
||||||
|
|
||||||
if self.ring is None or self.ring_fft is None:
|
if self.ring is None or self.ring_fft is None:
|
||||||
return True
|
return True
|
||||||
@ -131,17 +168,18 @@ class RingBuffer:
|
|||||||
self.ring_fft[row_idx, :] = fft_mag
|
self.ring_fft[row_idx, :] = fft_mag
|
||||||
|
|
||||||
if self.last_freqs is not None:
|
if self.last_freqs is not None:
|
||||||
self.distance_axis = compute_distance_axis(
|
self._promote_distance_axis(
|
||||||
|
compute_distance_axis(
|
||||||
self.last_freqs,
|
self.last_freqs,
|
||||||
self.fft_bins,
|
self.fft_bins,
|
||||||
mode=self.fft_mode,
|
mode=self.fft_mode,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
last_idx = (self.head - 1) % self.max_sweeps
|
last_idx = (self.head - 1) % self.max_sweeps
|
||||||
if self.ring_fft.shape[0] > 0:
|
if self.ring_fft.shape[0] > 0:
|
||||||
last_fft = self.ring_fft[last_idx]
|
last_fft = self.ring_fft[last_idx]
|
||||||
self.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy()
|
self._promote_fft_cache(last_fft)
|
||||||
self.last_fft_db = fft_mag_to_db(last_fft)
|
|
||||||
finite = self.ring_fft[np.isfinite(self.ring_fft)]
|
finite = self.ring_fft[np.isfinite(self.ring_fft)]
|
||||||
if finite.size > 0:
|
if finite.size > 0:
|
||||||
finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False))
|
finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False))
|
||||||
@ -170,6 +208,7 @@ class RingBuffer:
|
|||||||
row = np.full((self.width,), np.nan, dtype=np.float32)
|
row = np.full((self.width,), np.nan, dtype=np.float32)
|
||||||
take = min(self.width, int(sweep.size))
|
take = min(self.width, int(sweep.size))
|
||||||
row[:take] = np.asarray(sweep[:take], dtype=np.float32)
|
row[:take] = np.asarray(sweep[:take], dtype=np.float32)
|
||||||
|
self.last_push_valid_points = int(np.count_nonzero(np.isfinite(row[:take])))
|
||||||
self.ring[self.head, :] = row
|
self.ring[self.head, :] = row
|
||||||
self.ring_time[self.head] = time.time()
|
self.ring_time[self.head] = time.time()
|
||||||
if freqs is not None:
|
if freqs is not None:
|
||||||
@ -183,16 +222,8 @@ class RingBuffer:
|
|||||||
|
|
||||||
fft_mag = compute_fft_mag_row(fft_source, freqs, self.fft_bins, mode=self.fft_mode)
|
fft_mag = compute_fft_mag_row(fft_source, freqs, self.fft_bins, mode=self.fft_mode)
|
||||||
self.ring_fft[self.head, :] = fft_mag
|
self.ring_fft[self.head, :] = fft_mag
|
||||||
self.last_fft_mag = np.asarray(fft_mag, dtype=np.float32).copy()
|
self._promote_fft_cache(fft_mag)
|
||||||
self.last_fft_db = fft_mag_to_db(fft_mag)
|
self._promote_distance_axis(compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode))
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
self.distance_axis = compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode)
|
|
||||||
self.head = (self.head + 1) % self.max_sweeps
|
self.head = (self.head + 1) % self.max_sweeps
|
||||||
|
|
||||||
def get_display_raw(self) -> np.ndarray:
|
def get_display_raw(self) -> np.ndarray:
|
||||||
|
|||||||
@ -9,9 +9,15 @@ from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MI
|
|||||||
from rfg_adc_plotter.gui.pyqtgraph_backend import (
|
from rfg_adc_plotter.gui.pyqtgraph_backend import (
|
||||||
apply_working_range,
|
apply_working_range,
|
||||||
apply_working_range_to_aux_curves,
|
apply_working_range_to_aux_curves,
|
||||||
|
build_main_window_layout,
|
||||||
coalesce_packets_for_ui,
|
coalesce_packets_for_ui,
|
||||||
compute_background_subtracted_bscan_levels,
|
compute_background_subtracted_bscan_levels,
|
||||||
decimate_curve_for_display,
|
decimate_curve_for_display,
|
||||||
|
resolve_axis_bounds,
|
||||||
|
resolve_heavy_refresh_stride,
|
||||||
|
resolve_initial_window_size,
|
||||||
|
sanitize_curve_data_for_display,
|
||||||
|
sanitize_image_for_display,
|
||||||
resolve_visible_fft_curves,
|
resolve_visible_fft_curves,
|
||||||
resolve_visible_aux_curves,
|
resolve_visible_aux_curves,
|
||||||
)
|
)
|
||||||
@ -253,6 +259,72 @@ class ProcessingTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(kept), 1)
|
self.assertEqual(len(kept), 1)
|
||||||
self.assertEqual(int(kept[0][1]["sweep"]), 1)
|
self.assertEqual(int(kept[0][1]["sweep"]), 1)
|
||||||
|
|
||||||
|
def test_coalesce_packets_for_ui_switches_to_latest_only_on_large_backlog(self):
|
||||||
|
packets = [
|
||||||
|
(np.asarray([float(idx)], dtype=np.float32), {"sweep": idx}, None)
|
||||||
|
for idx in range(40)
|
||||||
|
]
|
||||||
|
|
||||||
|
kept, skipped = coalesce_packets_for_ui(packets, max_packets=8, backlog_packets=40)
|
||||||
|
|
||||||
|
self.assertEqual(skipped, 39)
|
||||||
|
self.assertEqual(len(kept), 1)
|
||||||
|
self.assertEqual(int(kept[0][1]["sweep"]), 39)
|
||||||
|
|
||||||
|
def test_resolve_heavy_refresh_stride_increases_with_backlog(self):
|
||||||
|
self.assertEqual(resolve_heavy_refresh_stride(0, max_packets=8), 1)
|
||||||
|
self.assertEqual(resolve_heavy_refresh_stride(20, max_packets=8), 2)
|
||||||
|
self.assertEqual(resolve_heavy_refresh_stride(40, max_packets=8), 4)
|
||||||
|
|
||||||
|
def test_sanitize_curve_data_for_display_rejects_fully_nonfinite_series(self):
|
||||||
|
xs, ys = sanitize_curve_data_for_display(
|
||||||
|
np.asarray([np.nan, np.nan], dtype=np.float64),
|
||||||
|
np.asarray([np.nan, np.nan], dtype=np.float32),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(xs.shape, (0,))
|
||||||
|
self.assertEqual(ys.shape, (0,))
|
||||||
|
|
||||||
|
def test_sanitize_image_for_display_rejects_fully_nonfinite_frame(self):
|
||||||
|
data = sanitize_image_for_display(np.full((4, 4), np.nan, dtype=np.float32))
|
||||||
|
|
||||||
|
self.assertIsNone(data)
|
||||||
|
|
||||||
|
def test_resolve_axis_bounds_rejects_nonfinite_ranges(self):
|
||||||
|
bounds = resolve_axis_bounds(np.asarray([np.nan, np.inf], dtype=np.float64))
|
||||||
|
|
||||||
|
self.assertIsNone(bounds)
|
||||||
|
|
||||||
|
def test_resolve_initial_window_size_stays_within_small_screen(self):
|
||||||
|
width, height = resolve_initial_window_size(800, 480)
|
||||||
|
|
||||||
|
self.assertLessEqual(width, 800)
|
||||||
|
self.assertLessEqual(height, 480)
|
||||||
|
self.assertGreaterEqual(width, 640)
|
||||||
|
self.assertGreaterEqual(height, 420)
|
||||||
|
|
||||||
|
def test_build_main_window_layout_uses_splitter_and_scroll_area(self):
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
try:
|
||||||
|
from PyQt5 import QtCore, QtWidgets
|
||||||
|
except Exception as exc: # pragma: no cover - environment-dependent
|
||||||
|
self.skipTest(f"Qt unavailable: {exc}")
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||||
|
main_window = QtWidgets.QWidget()
|
||||||
|
try:
|
||||||
|
_layout, splitter, _plot_layout, settings_widget, settings_layout, settings_scroll = build_main_window_layout(
|
||||||
|
QtCore,
|
||||||
|
QtWidgets,
|
||||||
|
main_window,
|
||||||
|
)
|
||||||
|
self.assertIsInstance(splitter, QtWidgets.QSplitter)
|
||||||
|
self.assertIsInstance(settings_scroll, QtWidgets.QScrollArea)
|
||||||
|
self.assertIs(settings_scroll.widget(), settings_widget)
|
||||||
|
self.assertIsInstance(settings_layout, QtWidgets.QVBoxLayout)
|
||||||
|
finally:
|
||||||
|
main_window.close()
|
||||||
|
|
||||||
def test_background_subtracted_bscan_levels_ignore_zero_floor(self):
|
def test_background_subtracted_bscan_levels_ignore_zero_floor(self):
|
||||||
disp_fft_lin = np.zeros((4, 8), dtype=np.float32)
|
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[1, 2:6] = np.asarray([0.05, 0.1, 0.5, 2.0], dtype=np.float32)
|
||||||
|
|||||||
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import unittest
|
import unittest
|
||||||
|
import warnings
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from rfg_adc_plotter.processing.fft import compute_fft_mag_row
|
from rfg_adc_plotter.processing.fft import compute_fft_mag_row
|
||||||
from rfg_adc_plotter.state.ring_buffer import RingBuffer
|
from rfg_adc_plotter.state.ring_buffer import RingBuffer
|
||||||
@ -116,6 +118,45 @@ class RingBufferTests(unittest.TestCase):
|
|||||||
self.assertEqual(ring.width, 0)
|
self.assertEqual(ring.width, 0)
|
||||||
self.assertEqual(ring.head, 0)
|
self.assertEqual(ring.head, 0)
|
||||||
|
|
||||||
|
def test_ring_buffer_push_ignores_all_nan_fft_without_runtime_warning(self):
|
||||||
|
ring = RingBuffer(max_sweeps=2)
|
||||||
|
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
|
||||||
|
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs)
|
||||||
|
fft_before = ring.last_fft_db.copy()
|
||||||
|
y_min_before = ring.y_min_fft
|
||||||
|
y_max_before = ring.y_max_fft
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", RuntimeWarning)
|
||||||
|
with patch(
|
||||||
|
"rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row",
|
||||||
|
return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32),
|
||||||
|
):
|
||||||
|
ring.push(np.linspace(1.0, 2.0, 64, dtype=np.float32), freqs)
|
||||||
|
|
||||||
|
self.assertFalse(ring.last_push_fft_valid)
|
||||||
|
self.assertTrue(np.allclose(ring.last_fft_db, fft_before))
|
||||||
|
self.assertEqual(ring.y_min_fft, y_min_before)
|
||||||
|
self.assertEqual(ring.y_max_fft, y_max_before)
|
||||||
|
|
||||||
|
def test_ring_buffer_set_fft_mode_ignores_all_nan_rebuild_without_runtime_warning(self):
|
||||||
|
ring = RingBuffer(max_sweeps=2)
|
||||||
|
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
|
||||||
|
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs)
|
||||||
|
fft_before = ring.last_fft_db.copy()
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", RuntimeWarning)
|
||||||
|
with patch(
|
||||||
|
"rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row",
|
||||||
|
return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32),
|
||||||
|
):
|
||||||
|
ring.set_fft_mode("direct")
|
||||||
|
|
||||||
|
self.assertFalse(ring.last_push_fft_valid)
|
||||||
|
self.assertTrue(np.allclose(ring.last_fft_db, fft_before))
|
||||||
|
self.assertEqual(ring.fft_mode, "direct")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user