This commit is contained in:
awe
2026-04-10 16:20:48 +03:00
parent 9aac162320
commit 934ca33d58
4 changed files with 531 additions and 145 deletions

View File

@ -42,6 +42,151 @@ RAW_PLOT_MAX_POINTS = 4096
RAW_WATERFALL_MAX_POINTS = 2048
UI_MAX_PACKETS_PER_TICK = 8
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]]:
@ -247,10 +392,18 @@ def coalesce_packets_for_ui(
packets: Sequence[SweepPacket],
*,
max_packets: int = UI_MAX_PACKETS_PER_TICK,
backlog_packets: Optional[int] = None,
) -> Tuple[List[SweepPacket], int]:
"""Keep only the newest packets so a burst cannot starve the Qt event loop."""
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:
return packet_list, 0
skipped = len(packet_list) - limit
@ -381,23 +534,16 @@ def run_pyqtgraph(args) -> None:
except Exception:
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)
main_layout.addWidget(win)
settings_widget = QtWidgets.QWidget()
settings_layout = QtWidgets.QVBoxLayout(settings_widget)
settings_layout.setContentsMargins(6, 6, 6, 6)
settings_layout.setSpacing(8)
try:
settings_widget.setMinimumWidth(250)
settings_widget.setMaximumWidth(340)
except Exception:
pass
main_layout.addWidget(settings_widget)
(
_main_layout,
splitter,
plot_layout,
settings_widget,
settings_layout,
settings_scroll,
) = build_main_window_layout(QtCore, QtWidgets, main_window)
plot_layout.addWidget(win)
p_line = win.addPlot(row=0, col=0, title="Сырые данные")
p_line.showGrid(x=True, y=True, alpha=0.3)
@ -597,8 +743,13 @@ def run_pyqtgraph(args) -> None:
fft_imag_enabled = True
fft_mode = "symmetric"
status_note = ""
status_note_expires_at: Optional[float] = None
status_dirty = True
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
if args.ylim:
try:
@ -615,50 +766,37 @@ def run_pyqtgraph(args) -> None:
return
f_min = float(runtime.range_min_ghz)
f_max = float(runtime.range_max_ghz)
if runtime.current_freqs is not None and runtime.current_freqs.size > 0:
finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)]
if finite_f.size > 0:
f_min = float(np.min(finite_f))
f_max = float(np.max(finite_f))
img.setImage(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS), autoLevels=False)
freq_bounds = resolve_axis_bounds(runtime.current_freqs)
if freq_bounds is not None:
f_min, f_max = freq_bounds
disp_raw = sanitize_image_for_display(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS))
if disp_raw is not None:
img.setImage(disp_raw, autoLevels=False)
img.setRect(0, f_min, max_sweeps, max(1e-9, 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)
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)
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(0.0, 1.0), padding=0)
p_fft.setXRange(0.0, 1.0, padding=0)
def update_physical_axes() -> None:
if runtime.current_freqs is not None and runtime.current_freqs.size > 0:
finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)]
if finite_f.size > 0:
f_min = float(np.min(finite_f))
f_max = float(np.max(finite_f))
if f_max <= f_min:
f_max = f_min + 1.0
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
freq_bounds = resolve_axis_bounds(runtime.current_freqs)
if freq_bounds is None:
freq_bounds = resolve_axis_bounds(
np.asarray([runtime.range_min_ghz, runtime.range_max_ghz], dtype=np.float64)
)
if freq_bounds is not None:
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)
distance_axis = runtime.ring.distance_axis
if distance_axis is not None and distance_axis.size > 0:
d_min = float(distance_axis[0])
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
distance_bounds = resolve_axis_bounds(runtime.ring.distance_axis)
if distance_bounds is not None:
d_min, d_max = distance_bounds
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)
@ -685,11 +823,28 @@ def run_pyqtgraph(args) -> None:
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
def set_status_note(note: str, ttl_s: Optional[float] = None) -> None:
nonlocal status_note, status_note_expires_at, status_dirty
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
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:
try:
path = calib_path_edit.text().strip()
@ -836,7 +991,13 @@ def run_pyqtgraph(args) -> None:
runtime.current_fft_complex = None
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:
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)
def set_calib_enabled() -> None:
@ -1261,19 +1422,26 @@ def run_pyqtgraph(args) -> None:
processed_frames = 0
ui_frames_skipped = 0
ui_started_at = time.perf_counter()
update_ticks = 0
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,
runtime.current_freqs,
bins,
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)
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] = []
while True:
try:
@ -1281,12 +1449,31 @@ def run_pyqtgraph(args) -> None:
except Empty:
break
drained = len(pending_packets)
last_queue_backlog = drained
if drained <= 0:
last_backlog_skipped = 0
last_heavy_refresh_stride = 1
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
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:
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)
runtime.full_current_aux_curves = None
runtime.full_current_fft_source = None
@ -1364,15 +1551,22 @@ def run_pyqtgraph(args) -> None:
pass
def update() -> None:
nonlocal peak_ref_window, status_dirty
nonlocal peak_ref_window, status_dirty, update_ticks
norm_display_scale = 500.0
if peak_calibrate_mode and any(edit.hasFocus() for edit in c_edits):
return
if peak_search_enabled and peak_window_edit is not None and peak_window_edit.hasFocus():
return
update_ticks += 1
clear_expired_status_note()
changed = drain_queue() > 0
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:
xs = resolve_curve_xs(
@ -1385,6 +1579,7 @@ def run_pyqtgraph(args) -> 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 = sanitize_curve_data_for_display(raw_x, raw_y)
curve.setData(raw_x, raw_y, autoDownsample=False)
else:
curve.setData([], [])
@ -1394,6 +1589,8 @@ def run_pyqtgraph(args) -> None:
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_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_2.setData(aux_x_2, aux_y_2, autoDownsample=False)
else:
@ -1408,6 +1605,7 @@ def run_pyqtgraph(args) -> None:
displayed_calib = runtime.calib_envelope
xs_calib = resolve_curve_xs(displayed_calib.size)
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)
else:
curve_calib.setData([], [])
@ -1415,6 +1613,7 @@ def run_pyqtgraph(args) -> None:
if runtime.current_sweep_norm is not None:
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 = sanitize_curve_data_for_display(norm_x, norm_y)
curve_norm.setData(norm_x, norm_y, autoDownsample=False)
else:
curve_norm.setData([], [])
@ -1431,10 +1630,9 @@ def run_pyqtgraph(args) -> None:
if y_limits is not None:
p_line.setYRange(y_limits[0], y_limits[1], padding=0)
if isinstance(xs, np.ndarray) and xs.size > 0:
finite_x = xs[np.isfinite(xs)]
if finite_x.size > 0:
p_line.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0)
line_x_bounds = resolve_axis_bounds(xs)
if line_x_bounds is not None:
p_line.setXRange(line_x_bounds[0], line_x_bounds[1], padding=0)
sweep_for_fft = runtime.current_fft_input
if sweep_for_fft is None:
@ -1470,9 +1668,9 @@ def run_pyqtgraph(args) -> None:
set_status_note(f"фон: не удалось применить ({exc})")
active_background = None
display_fft_mag = fft_mag
finite_x = xs_fft[np.isfinite(xs_fft)]
if finite_x.size > 0:
p_fft.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0)
fft_x_bounds = resolve_axis_bounds(xs_fft)
if fft_x_bounds is not None:
p_fft.setXRange(fft_x_bounds[0], fft_x_bounds[1], padding=0)
fft_vals_db = fft_mag_to_db(display_fft_mag)
ref_curve_for_range = None
@ -1487,15 +1685,18 @@ def run_pyqtgraph(args) -> None:
show_imag=fft_imag_enabled,
)
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:
curve_fft.setData([], [])
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:
curve_fft_real.setData([], [])
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:
curve_fft_imag.setData([], [])
@ -1504,7 +1705,8 @@ def run_pyqtgraph(args) -> None:
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref_db)
if np.any(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)
ref_curve_for_range = fft_ref_lin
else:
@ -1573,7 +1775,8 @@ def run_pyqtgraph(args) -> None:
curve_fft_real.setData([], [])
curve_fft_imag.setData([], [])
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:
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)
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_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)
y_for_range = np.concatenate((y_for_range, fft_ref[finite_ref]))
else:
@ -1668,12 +1872,22 @@ def run_pyqtgraph(args) -> None:
runtime.plot_dirty = False
if changed and runtime.ring.ring is not None:
disp = runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS)
levels = _visible_levels_pyqtgraph(disp, p_img)
if levels is not None:
img.setImage(disp, autoLevels=False, levels=levels)
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)
if levels is not None:
img.setImage(disp, autoLevels=False, levels=levels)
else:
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:
img.setImage(disp, autoLevels=False)
log_debug_event(
"suppressed_image_refresh",
f"ui waterfall refresh suppressed stride:{last_heavy_refresh_stride}",
)
if redraw_needed or status_dirty:
try:
@ -1683,8 +1897,16 @@ def run_pyqtgraph(args) -> None:
if peak_calibrate_mode and runtime.current_peak_amplitude is not None:
status_payload["peak_a"] = runtime.current_peak_amplitude
base_status = format_status_kv(status_payload) if status_payload else ""
status_parts = []
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:
status.setText(base_status)
except Exception:
@ -1713,66 +1935,77 @@ def run_pyqtgraph(args) -> None:
pass
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()
if disp_times is not None:
now_t = time.time()
mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec))
if np.any(mask):
try:
mean_spec = np.nanmean(disp_fft_lin[:, mask], axis=1)
mean_spec = np.nan_to_num(mean_spec, nan=0.0)
disp_fft_lin = disp_fft_lin - mean_spec[:, None]
except Exception:
pass
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()
if spec_mean_sec > 0.0:
disp_times = runtime.ring.get_display_times()
if disp_times is not None:
now_t = time.time()
mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec))
if np.any(mask):
try:
mean_spec = np.nanmean(disp_fft_lin[:, mask], axis=1)
mean_spec = np.nan_to_num(mean_spec, nan=0.0)
disp_fft_lin = disp_fft_lin - mean_spec[:, None]
except Exception:
pass
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 active_background is not None:
levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft)
if levels is None:
try:
mean_spec = np.nanmean(disp_fft, axis=1)
vmin_v = float(np.nanmin(mean_spec))
vmax_v = float(np.nanmax(mean_spec))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
levels = (vmin_v, vmax_v)
active_background = resolve_active_background(disp_fft_lin.shape[0])
except Exception:
levels = None
if levels is None and spec_clip is not None:
active_background = None
if active_background is not None:
try:
vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0]))
vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1]))
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)
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
if active_background is not None:
levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft)
if levels is None:
try:
mean_spec = np.nanmean(disp_fft, axis=1)
vmin_v = float(np.nanmin(mean_spec))
vmax_v = float(np.nanmax(mean_spec))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
levels = (vmin_v, vmax_v)
except Exception:
levels = None
if (
levels is None
and runtime.ring.y_min_fft is not None
and runtime.ring.y_max_fft is not None
and np.isfinite(runtime.ring.y_min_fft)
and np.isfinite(runtime.ring.y_max_fft)
and runtime.ring.y_min_fft != runtime.ring.y_max_fft
):
levels = (runtime.ring.y_min_fft, runtime.ring.y_max_fft)
if levels is not None:
img_fft.setImage(disp_fft, autoLevels=False, levels=levels)
else:
img_fft.setImage(disp_fft, autoLevels=False)
if levels is None and spec_clip is not None:
try:
vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0]))
vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1]))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
levels = (vmin_v, vmax_v)
except Exception:
levels = None
if (
levels is None
and runtime.ring.y_min_fft is not None
and runtime.ring.y_max_fft is not None
and np.isfinite(runtime.ring.y_min_fft)
and np.isfinite(runtime.ring.y_max_fft)
and runtime.ring.y_min_fft != runtime.ring.y_max_fft
):
levels = (runtime.ring.y_min_fft, runtime.ring.y_max_fft)
if levels is not None:
img_fft.setImage(disp_fft, autoLevels=False, levels=levels)
else:
img_fft.setImage(disp_fft, autoLevels=False)
timer = pg.QtCore.QTimer()
timer.timeout.connect(update)
@ -1842,7 +2075,16 @@ def run_pyqtgraph(args) -> None:
app.aboutToQuit.connect(on_quit)
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:
pass
main_window.show()

View File

@ -31,6 +31,9 @@ class RingBuffer:
self.last_freqs: Optional[np.ndarray] = None
self.y_min_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
def is_ready(self) -> bool:
@ -55,6 +58,38 @@ class RingBuffer:
self.last_freqs = None
self.y_min_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:
"""Allocate or resize buffers. Returns True when geometry changed."""
@ -107,6 +142,8 @@ class RingBuffer:
self.fft_mode = normalized_mode
self.y_min_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:
return True
@ -131,17 +168,18 @@ class RingBuffer:
self.ring_fft[row_idx, :] = fft_mag
if self.last_freqs is not None:
self.distance_axis = compute_distance_axis(
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
self._promote_distance_axis(
compute_distance_axis(
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
)
)
last_idx = (self.head - 1) % self.max_sweeps
if self.ring_fft.shape[0] > 0:
last_fft = self.ring_fft[last_idx]
self.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(last_fft)
self._promote_fft_cache(last_fft)
finite = self.ring_fft[np.isfinite(self.ring_fft)]
if finite.size > 0:
finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False))
@ -170,6 +208,7 @@ class RingBuffer:
row = np.full((self.width,), np.nan, dtype=np.float32)
take = min(self.width, int(sweep.size))
row[:take] = np.asarray(sweep[:take], dtype=np.float32)
self.last_push_valid_points = int(np.count_nonzero(np.isfinite(row[:take])))
self.ring[self.head, :] = row
self.ring_time[self.head] = time.time()
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)
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:
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._promote_fft_cache(fft_mag)
self._promote_distance_axis(compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode))
self.head = (self.head + 1) % self.max_sweeps
def get_display_raw(self) -> np.ndarray: