diff --git a/Horns_clean.py b/Horns_clean.py new file mode 100644 index 0000000..4b6c693 --- /dev/null +++ b/Horns_clean.py @@ -0,0 +1,1086 @@ +""" +MIMO GPR — coherent time-domain BackProjection +================================================= + +Эта ячейка полностью независима от верхнего эллипсного алгоритма: + 1. загружает те же S21-данные; + 2. строит oversampled A-сканы через тот же частотный сдвиг перед IFFT; + 3. для каждой точки (x,z) вычисляет tau_ij = (Rtx + Rrx) / v; + 4. интерполирует комплексный A-скан в этой задержке; + 5. когерентно суммирует комплексные вклады всех Tx/Rx-пар с компенсацией geo·pattern. + +Это coherent BP: суммируются комплексные h_ij(tau), затем строится |sum h_ij|. +""" + +import numpy as np +import matplotlib.pyplot as plt +from scipy.ndimage import gaussian_filter, label +from pathlib import Path +import contextlib +import io + +# ══════════════════════════════════════════════════════ +# 0.1 ИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ +# ══════════════════════════════════════════════════════ +INPUT_IDX = [0, 1, 2, 3] +OUTPUT_IDX = [2, 3] + +# Частотный диапазон и глубинный gate. +F_START = 7 * 1e8 +F_STOP = 6 * 1e9 +MIN_DEPTH = 4.0 +MAX_DEPTH = 12.0 + +# Данные и вычитание среднего фона. +BG_SUBTRACT = True +BG_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/20260514/20260514_s1_10-6000_1201_50k/preprocessed') +DATA_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/20260514/20260514_s1_10-6000_1201_50k/preprocessed/0004_id1_ns11951298408967') + +# Убрать паразитные боковые лепестки с 2D карты. +BP_REMOVE_SIDELOBE_OBJECTS = True # True: убрать SL-кандидаты из финальной таблицы и разметки +BP_MAX_DETECTED_OBJECTS_TO_DRAW = 8 # N: если найдено больше объектов, цели на карте не рисуются +BP_DRAW_TOP_M_OBJECTS = 4 # M: если найдено <= N, рисуются только первые M по текущему score +BP_OBJECT_MIN_FRAC = 0.7 # остановка: пик ниже этой доли от глобального максимума + +# Компенсация затухания разделена на 2 части: +# range: геометрическое расхождение 1/(Rtx*Rrx) +# angle: диаграмма направленности cos_tx^2 * cos_rx^2 +COMP_RANGE_POWER = 0.1 + +# Метрика для ранжирования целей. +# 'peak' — порядок по максимумам coherent BP; +# 'combined' — coherent peak + coherence factor + prominence + contrast. +BP_SCORE_MODE = 'combined' + +# ══════════════════════════════════════════════════════ +# 0.2 КОНФИГИ АНТЕНН +# ══════════════════════════════════════════════════════ + +# Физические координаты антенн по их реальным индексам, [м]. +# Формат: (x, y, z), где X - поперечная ось, Z - дальность вдоль оси радара, +# Y - нормаль к плоскости XOZ. BP-карта строится в плоскости y=BP_PLANE_Y. +# Для индивидуальных смещений Tx меняйте второй/третий элемент tuple у каждой Tx. +BP_PLANE_Y = 0.0 + +TX_POSITIONS = { + 2: (-128.7 * 0.01, 0.375, 0.0), + 3: ( 130.7 * 0.01, 0.375, 0.0), +} + +RX_POSITIONS = { + 0: ( 25.8 * 0.01, 0.0, 0.0), + 1: ( 75.0 * 0.01, 0.0, 0.0), + 2: (-75.0 * 0.01, 0.0, 0.0), + 3: (-24.5 * 0.01, 0.0, 0.0), +} + +# ══════════════════════════════════════════════════════ +# 0.3 НЕИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ (ЛУЧШЕ НЕ ТРОГАТЬ) +# ══════════════════════════════════════════════════════ + +eps_r = 1.0 +v = 3e8 / np.sqrt(eps_r) + + +def positions_to_xyz(position_dict): + coords = [] + for idx in sorted(position_dict): + pos = np.asarray(position_dict[idx], dtype=float) + if pos.ndim == 0: + pos = np.array([float(pos), 0.0, 0.0], dtype=float) + elif pos.shape == (2,): + pos = np.array([pos[0], pos[1], 0.0], dtype=float) + elif pos.shape != (3,): + raise ValueError(f'Позиция антенны {idx} должна быть x, (x,y) или (x,y,z), получено {pos}') + coords.append(pos) + return np.vstack(coords) + + +tx_xyz = positions_to_xyz(TX_POSITIONS) +rx_xyz = positions_to_xyz(RX_POSITIONS) +x_tx, y_tx, z_tx = tx_xyz.T +x_rx, y_rx, z_rx = rx_xyz.T + +# Сетка BP-карты. +x_ant = np.concatenate([x_tx, x_rx]) +x_min, x_max = x_ant.min() - 2.0, x_ant.max() + 2.0 +z_min, z_max = 0.1, MAX_DEPTH +NX_BP = 300 +NZ_BP = 300 + +# Oversampling A-сканов: повышает плотность точек по t, но не физическое разрешение. +BP_OVERSAMPLE = 8 +BP_WINDOW = True + +# Нормировка каналов Tx/Rx перед когерентным сложением. +# Масштаб считается по |complex A-scan| в выбранном диапазоне глубин. +PAIR_NORMALIZE = True +PAIR_NORM_PERCENTILE = 50.0 +PAIR_NORM_DEPTH_MIN = MIN_DEPTH +PAIR_NORM_DEPTH_MAX = MAX_DEPTH +PAIR_NORM_EPS = 1e-15 + +# Компенсация нормируется на точку под виртуальным центром пары на глубине COMP_REF_DEPTH. +COMP_ANGLE_POWER = 0.0 +COMP_RANGE_WEIGHT_MAX = 5.0 +COMP_ANGLE_WEIGHT_MAX = 2.0 +COMP_WEIGHT_MAX = 8.0 +COMP_REF_DEPTH = 5.0 +BP_VALIDATE_COMPENSATION = False # clean mode: не считаем отдельную карту без compensation + +# Сглаживание только для удобства поиска/визуализации максимума. +BP_SMOOTH_SIGMA = 1.5 + +# Параметры поиска объектов на BP-карте. +MAX_OBJECTS = 10 +BP_ONLY_MAP_OUTPUT = True # True: подавить текстовые выводы и оставить только BP-карту +BP_REGION_THRESH_FRAC = 0.75 # область объекта: связная область выше этой доли от локального пика +BP_SUPPRESS_THRESH_FRAC = 0.2 # подавление: более широкая связная область вокруг найденного пика +BP_SUPPRESS_USE_WINDOW = True # False: подавлять всю связанную область; True: ограничить окно вокруг пика +BP_SUPPRESS_RX_CM = 80.0 # используется только если BP_SUPPRESS_USE_WINDOW=True +BP_SUPPRESS_RZ_CM = 40.0 # используется только если BP_SUPPRESS_USE_WINDOW=True +BP_MIN_REGION_AREA_CM2 = 10.0 # отсечение совсем мелких шумовых пятен + +# Компактный центроид вокруг локального максимума, устойчивее центроида всей вытянутой области. +BP_CENTER_USE_COMPACT = True +BP_CENTER_RX_CM = 60.0 +BP_CENTER_RZ_CM = 25.0 +BP_CENTER_THRESH_FRAC = 0.88 +BP_CENTER_WEIGHT_POWER = 2.0 + +# Диагностика боковых лепестков coherent BP. +BP_SIDELOBE_DETECT = True +BP_SIDELOBE_RANGE_RMS_TOL_CM = 20.0 # RMS-разница бистатических глубин по всем парам +BP_SIDELOBE_MIN_DX_CM = 35.0 # боковой лепесток должен быть заметно смещен по X +BP_SIDELOBE_MAX_DZ_CM = 70.0 # но находиться примерно на той же глубине +BP_SIDELOBE_MAX_REL_PEAK = 0.85 # кандидат должен быть слабее родительского максимума + +# Фазовая метрика внутри compact-области объекта. +BP_PHASE_WEIGHT_POWER = 1.0 + +# Локальная выраженность объекта над окружающим фоном. +BP_LOCAL_BG_RX_CM = 120.0 +BP_LOCAL_BG_RZ_CM = 80.0 +BP_LOCAL_BG_PERCENTILE = 50.0 +BP_LOCAL_CONTRAST_EPS = 1e-12 + +# Экспериментальный score для ранжирования найденных объектов. +BP_SCORE_COMPUTE_INCOHERENT = True +BP_SCORE_COH_PEAK_WEIGHT = 0.45 +BP_SCORE_COHERENCE_FACTOR_WEIGHT = 0.25 +BP_SCORE_PROMINENCE_WEIGHT = 0.20 +BP_SCORE_CONTRAST_WEIGHT = 0.10 +BP_SCORE_CONTRAST_CAP = 6.0 +BP_SCORE_CF_EPS = 1e-12 + +if BP_ONLY_MAP_OUTPUT: + _bp_stdout_buffer = io.StringIO() + _bp_stdout_redirect = contextlib.redirect_stdout(_bp_stdout_buffer) + _bp_stdout_redirect.__enter__() + + +# ══════════════════════════════════════════════════════ +# 1. ЗАГРУЗКА ДАННЫХ +# ══════════════════════════════════════════════════════ + +def load_mimo_data(data_path, input_idx, output_idx): + data_path = Path(data_path) + s21_data = {} + freq_data = {} + + for f in data_path.glob('i*_o*_s21.npy'): + name = f.stem + parts = name.split('_') + i_tx_phys = int(parts[1][1:]) + i_rx_phys = int(parts[0][1:]) + + if i_tx_phys not in output_idx or i_rx_phys not in input_idx: + continue + + i_tx = sorted(output_idx).index(i_tx_phys) + i_rx = sorted(input_idx).index(i_rx_phys) + + s21_data[(i_tx, i_rx)] = np.load(f) + freq_file = data_path / f'i{i_rx_phys}_o{i_tx_phys}_freq.npy' + freq_data[(i_tx, i_rx)] = np.load(freq_file) + + return s21_data, freq_data + + +def compute_background(bg_path, input_idx, output_idx): + bg_path = Path(bg_path) + snapshots = [s for s in sorted(bg_path.glob('*/')) if s.is_dir()] + + if len(snapshots) == 0: + print('Фоновые снимки не найдены, BG_SUBTRACT отключен.') + return None + + print(f'Вычисление фона по {len(snapshots)} снимкам...', end=' ', flush=True) + bg_sum = {} + bg_count = {} + + for snap_dir in snapshots: + for f in snap_dir.glob('i*_o*_s21.npy'): + name = f.stem + parts = name.split('_') + i_tx_phys = int(parts[1][1:]) + i_rx_phys = int(parts[0][1:]) + + if i_tx_phys not in output_idx or i_rx_phys not in input_idx: + continue + + i_tx = sorted(output_idx).index(i_tx_phys) + i_rx = sorted(input_idx).index(i_rx_phys) + key = (i_tx, i_rx) + + s21 = np.load(f) + if key not in bg_sum: + bg_sum[key] = np.zeros_like(s21, dtype=np.complex128) + bg_count[key] = 0 + bg_sum[key] += s21 + bg_count[key] += 1 + + bg = {key: bg_sum[key] / bg_count[key] for key in bg_sum} + print(f'готово. Пар: {len(bg)}') + return bg + + +s21_data, freq_data = load_mimo_data(DATA_PATH, INPUT_IDX, OUTPUT_IDX) +N_tx = len(OUTPUT_IDX) +N_rx = len(INPUT_IDX) +N_pairs = len(s21_data) + +assert len(x_tx) == N_tx, f'x_tx должен содержать {N_tx} элементов' +assert len(x_rx) == N_rx, f'x_rx должен содержать {N_rx} элементов' +assert N_pairs > 0, 'Не найдено ни одной Tx/Rx-пары. Проверьте DATA_PATH.' + +print('Геометрия антенн, [м]:') +for local_i, phys_i in enumerate(sorted(TX_POSITIONS)): + print(f' Tx{local_i} / o{phys_i}: x={x_tx[local_i]:+.3f}, y={y_tx[local_i]:+.3f}, z={z_tx[local_i]:+.3f}') +for local_j, phys_j in enumerate(sorted(RX_POSITIONS)): + print(f' Rx{local_j} / i{phys_j}: x={x_rx[local_j]:+.3f}, y={y_rx[local_j]:+.3f}, z={z_rx[local_j]:+.3f}') + +if BG_SUBTRACT: + background = compute_background(BG_PATH, INPUT_IDX, OUTPUT_IDX) + if background is None: + BG_SUBTRACT = False +else: + background = None + print('BG_SUBTRACT = False, вычитание фона отключено.') + +first_key = list(freq_data.keys())[0] +freqs = freq_data[first_key] +freq_mask = (freqs >= F_START) & (freqs <= F_STOP) +freqs_bp = freqs[freq_mask] + +if len(freqs_bp) < 2: + raise ValueError('В выбранном частотном диапазоне меньше двух точек.') + +f_min = float(freqs_bp[0]) +f_max = float(freqs_bp[-1]) +BW = f_max - f_min +df_values = np.diff(freqs_bp) +df_median = float(np.median(df_values)) +df_min = float(np.min(df_values)) +df_max = float(np.max(df_values)) +df_rel_spread = (df_max - df_min) / (df_median + 1e-30) +range_resolution = v / (2 * BW) +unambiguous_depth = v / (2 * df_median) +unambiguous_total_path = v / df_median + +print(f'Загружено пар Tx/Rx: {N_pairs}') +print(f'Частотный диапазон BP: {f_min/1e9:.3f} - {f_max/1e9:.3f} ГГц') +print(f'Точек частоты в BP-диапазоне: {len(freqs_bp)}') +print(f'Шаг частоты df: median={df_median/1e6:.3f} МГц, ' + f'min={df_min/1e6:.3f} МГц, max={df_max/1e6:.3f} МГц') +print(f'Неравномерность df: {(df_rel_spread*100):.3f}% от median') +print(f'Полоса B: {BW/1e9:.3f} ГГц') +print(f'Теоретический предел разрешения по глубине deltaZ = {range_resolution*100:.2f} см') +print(f'Unambiguous range по глубине = {unambiguous_depth:.2f} м ' + f'(max total path = {unambiguous_total_path:.2f} м)') +print(f'BP_OVERSAMPLE = {BP_OVERSAMPLE}') + + +# ══════════════════════════════════════════════════════ +# 2. OVERSAMPLED A-СКАНЫ +# ══════════════════════════════════════════════════════ + +def compute_ascan_bp(s21, freq, f_start, f_stop, window=True, oversample=8): + """ + S21(f) -> A-скан с правильным частотным сдвигом и oversampling. + + Частотный шаг df остается тем же, а n_fft увеличивается в oversample раз. + Поэтому временная сетка становится плотнее: dt = 1 / (n_fft * df). + """ + mask = (freq >= f_start) & (freq <= f_stop) + freq_cut = freq[mask] + s21_cut = s21[mask] + + if len(freq_cut) < 2: + raise ValueError('После обрезки по частоте осталось меньше двух точек.') + + df = float(np.median(np.diff(freq_cut))) + n = len(freq_cut) + k0 = int(round(freq_cut[0] / df)) + + min_len = 2 * (k0 + n - 1) + n_fft_base = 1 << int(np.ceil(np.log2(min_len))) + n_fft = int(n_fft_base * oversample) + + dt = 1.0 / (n_fft * df) + t_sec = np.arange(n_fft, dtype=float) * dt + + s = s21_cut * np.hanning(n) if window else s21_cut.copy() + + H = np.zeros(n_fft, dtype=np.complex128) + H[k0:k0 + n] = s + + h_complex = np.fft.ifft(H) + a_abs = np.abs(h_complex) + + return t_sec, a_abs, h_complex, n_fft_base, n_fft + + +print('Вычисление oversampled A-сканов...', end=' ', flush=True) +A_bp = {} +H_bp = {} +T_bp = {} +Z_bp = {} +n_fft_info = {} +pair_norm_info = {} + +for (i, j), s21 in s21_data.items(): + s21_proc = s21.copy() + if BG_SUBTRACT and background is not None and (i, j) in background: + s21_proc = s21_proc - background[(i, j)] + + t_pair, a_pair, h_pair, n_fft_base, n_fft = compute_ascan_bp( + s21_proc, + freq_data[(i, j)], + f_start=F_START, + f_stop=F_STOP, + window=BP_WINDOW, + oversample=BP_OVERSAMPLE, + ) + T_bp[(i, j)] = t_pair + Z_bp[(i, j)] = t_pair * v / 2 + A_bp[(i, j)] = a_pair + H_bp[(i, j)] = h_pair + n_fft_info[(i, j)] = (n_fft_base, n_fft) + +# Robust per-pair amplitude normalization. It equalizes channel scale, not phase. +for key in sorted(H_bp.keys()): + z_axis = Z_bp[key] + h_abs = np.abs(H_bp[key]) + norm_mask = (z_axis >= PAIR_NORM_DEPTH_MIN) & (z_axis <= PAIR_NORM_DEPTH_MAX) + if not np.any(norm_mask): + norm_mask = np.ones_like(z_axis, dtype=bool) + + median_val = float(np.median(h_abs[norm_mask])) + p75_val = float(np.percentile(h_abs[norm_mask], 75)) + p95_val = float(np.percentile(h_abs[norm_mask], 95)) + scale = float(np.percentile(h_abs[norm_mask], PAIR_NORM_PERCENTILE)) + if not np.isfinite(scale) or scale <= PAIR_NORM_EPS: + scale = 1.0 + + pair_norm_info[key] = { + 'median': median_val, + 'p75': p75_val, + 'p95': p95_val, + 'scale': scale, + } + + if PAIR_NORMALIZE: + H_bp[key] = H_bp[key] / (scale + PAIR_NORM_EPS) + A_bp[key] = np.abs(H_bp[key]) + +z_h_bp = Z_bp[first_key] +t_h_bp = T_bp[first_key] +print('готово.') +print(f'dt = {(t_h_bp[1] - t_h_bp[0])*1e12:.2f} пс') +print(f'dz_sample = {(z_h_bp[1] - z_h_bp[0])*100:.3f} см') + +print('\n' + '=' * 86) +print(f' НОРМИРОВКА КАНАЛОВ Tx/Rx: enabled={PAIR_NORMALIZE}, ' + f'percentile={PAIR_NORM_PERCENTILE:.1f}, ' + f'z=[{PAIR_NORM_DEPTH_MIN:.2f}, {PAIR_NORM_DEPTH_MAX:.2f}] м') +print('=' * 86) +print(f" {'Pair':<8} {'median':>12} {'p75':>12} {'p95':>12} {'scale':>12} {'rel_scale':>12}") +print('-' * 86) +scales = np.array([v['scale'] for v in pair_norm_info.values()], dtype=float) +scale_ref = float(np.median(scales)) if len(scales) else 1.0 +for key in sorted(pair_norm_info.keys()): + info = pair_norm_info[key] + rel_scale = info['scale'] / (scale_ref + PAIR_NORM_EPS) + print(f" Tx{key[0]}-Rx{key[1]:<3} {info['median']:>12.4e} {info['p75']:>12.4e} " + f"{info['p95']:>12.4e} {info['scale']:>12.4e} {rel_scale:>12.3f}") +print('=' * 86) + + +# ══════════════════════════════════════════════════════ +# 3. TIME-DOMAIN COHERENT BACKPROJECTION +# ══════════════════════════════════════════════════════ + +x_grid_bp = np.linspace(x_min, x_max, NX_BP) +z_grid_bp = np.linspace(z_min, z_max, NZ_BP) +XX_bp, ZZ_bp = np.meshgrid(x_grid_bp, z_grid_bp) +depth_gate = (ZZ_bp >= MIN_DEPTH) & (ZZ_bp <= MAX_DEPTH) + + +def bistatic_ranges(i_tx, i_rx, XX, ZZ, yy=BP_PLANE_Y): + Rtx = np.sqrt((XX - x_tx[i_tx])**2 + (yy - y_tx[i_tx])**2 + (ZZ - z_tx[i_tx])**2) + Rrx = np.sqrt((XX - x_rx[i_rx])**2 + (yy - y_rx[i_rx])**2 + (ZZ - z_rx[i_rx])**2) + return Rtx, Rrx + + +def antenna_boresight_cos_z(R, z_ant, ZZ): + # В локальных координатах радара все антенны смотрят вдоль +Z. + return (ZZ - z_ant) / (R + 1e-12) + + +def attenuation_components_map(i_tx, i_rx, XX, ZZ): + Rtx, Rrx = bistatic_ranges(i_tx, i_rx, XX, ZZ) + geo = 1.0 / (Rtx * Rrx + 1e-12) + cos_tx = antenna_boresight_cos_z(Rtx, z_tx[i_tx], ZZ) + cos_rx = antenna_boresight_cos_z(Rrx, z_rx[i_rx], ZZ) + angle = cos_tx**2 * cos_rx**2 + return geo + 1e-30, angle + 1e-30 + + +def attenuation_components_at_ref_depth(i_tx, i_rx, z_ref): + xc = (x_tx[i_tx] + x_rx[i_rx]) / 2.0 + Rtx, Rrx = bistatic_ranges(i_tx, i_rx, xc, z_ref) + geo = 1.0 / (Rtx * Rrx + 1e-12) + cos_tx = antenna_boresight_cos_z(Rtx, z_tx[i_tx], z_ref) + cos_rx = antenna_boresight_cos_z(Rrx, z_rx[i_rx], z_ref) + angle = cos_tx**2 * cos_rx**2 + return geo + 1e-30, angle + 1e-30 + + +def bp_compensation_weight(i_tx, i_rx, XX, ZZ): + geo, angle = attenuation_components_map(i_tx, i_rx, XX, ZZ) + geo_ref, angle_ref = attenuation_components_at_ref_depth(i_tx, i_rx, COMP_REF_DEPTH) + + geo_norm = geo / geo_ref + angle_norm = angle / angle_ref + + range_weight = 1.0 / (geo_norm ** COMP_RANGE_POWER + 1e-12) + angle_weight = 1.0 / (angle_norm ** COMP_ANGLE_POWER + 1e-12) + + range_weight = np.clip(range_weight, 0.0, COMP_RANGE_WEIGHT_MAX) + angle_weight = np.clip(angle_weight, 0.0, COMP_ANGLE_WEIGHT_MAX) + + weight = range_weight * angle_weight + return np.clip(weight, 0.0, COMP_WEIGHT_MAX) + + +def interpolate_ascan_amplitude(tau, t_axis, a_axis): + return np.interp(tau.ravel(), t_axis, a_axis, left=0.0, right=0.0).reshape(tau.shape) + + +def interpolate_ascan_complex(tau, t_axis, h_axis): + h_real = np.interp(tau.ravel(), t_axis, h_axis.real, left=0.0, right=0.0) + h_imag = np.interp(tau.ravel(), t_axis, h_axis.imag, left=0.0, right=0.0) + return (h_real + 1j * h_imag).reshape(tau.shape) + + +def backproject_coherent(H, T, compensate=True): + bp_complex = np.zeros_like(XX_bp, dtype=np.complex128) + contribution_count = np.zeros_like(XX_bp, dtype=float) + + for i in range(N_tx): + for j in range(N_rx): + key = (i, j) + if key not in H: + continue + + Rtx, Rrx = bistatic_ranges(i, j, XX_bp, ZZ_bp) + tau = (Rtx + Rrx) / v + valid = depth_gate & (tau >= T[key][0]) & (tau <= T[key][-1]) + + h_tau = interpolate_ascan_complex(tau, T[key], H[key]) + h_tau = np.where(valid, h_tau, 0.0 + 0.0j) + + if compensate: + w = bp_compensation_weight(i, j, XX_bp, ZZ_bp) + w = np.where(valid, w, 0.0) + else: + w = np.where(valid, 1.0, 0.0) + + bp_complex += h_tau * w + contribution_count += valid.astype(float) + + bp_complex = bp_complex / (contribution_count + 1e-12) + bp_complex = np.where(depth_gate, bp_complex, 0.0 + 0.0j) + bp_abs = np.abs(bp_complex) + return bp_abs, bp_complex + + +def backproject_coherent_and_incoherent(H, T, compensate=True): + """Одним проходом строит coherent |sum h| и incoherent sum |h| BP-карты.""" + bp_complex = np.zeros_like(XX_bp, dtype=np.complex128) + bp_incoherent = np.zeros_like(XX_bp, dtype=float) + contribution_count = np.zeros_like(XX_bp, dtype=float) + + for i in range(N_tx): + for j in range(N_rx): + key = (i, j) + if key not in H: + continue + + Rtx, Rrx = bistatic_ranges(i, j, XX_bp, ZZ_bp) + tau = (Rtx + Rrx) / v + valid = depth_gate & (tau >= T[key][0]) & (tau <= T[key][-1]) + + h_tau = interpolate_ascan_complex(tau, T[key], H[key]) + h_tau = np.where(valid, h_tau, 0.0 + 0.0j) + + if compensate: + w = bp_compensation_weight(i, j, XX_bp, ZZ_bp) + w = np.where(valid, w, 0.0) + else: + w = np.where(valid, 1.0, 0.0) + + bp_complex += h_tau * w + bp_incoherent += np.abs(h_tau) * w + contribution_count += valid.astype(float) + + bp_complex = bp_complex / (contribution_count + 1e-12) + bp_complex = np.where(depth_gate, bp_complex, 0.0 + 0.0j) + bp_abs = np.abs(bp_complex) + + bp_incoherent = bp_incoherent / (contribution_count + 1e-12) + bp_incoherent = np.where(depth_gate, bp_incoherent, 0.0) + + bp_cf = bp_abs / (bp_incoherent + BP_SCORE_CF_EPS) + bp_cf = np.where(depth_gate, bp_cf, 0.0) + bp_cf = np.clip(bp_cf, 0.0, 1.0) + return bp_abs, bp_complex, bp_incoherent, bp_cf + + +def normalize_bp_map(bp_raw, smooth_sigma=BP_SMOOTH_SIGMA): + bp_norm = bp_raw / (bp_raw.max() + 1e-12) + bp_norm = gaussian_filter(bp_norm, sigma=smooth_sigma) + bp_norm = np.where(depth_gate, bp_norm, 0.0) + return bp_norm / (bp_norm.max() + 1e-12) + +def component_containing_peak(image, iz, ix, threshold, window_mask=None): + mask = image >= threshold + if window_mask is not None: + mask &= window_mask + + labels, n_labels = label(mask, structure=np.ones((3, 3), dtype=int)) + if n_labels == 0 or labels[iz, ix] == 0: + fallback = np.zeros_like(image, dtype=bool) + fallback[iz, ix] = True + return fallback + + return labels == labels[iz, ix] + + +def weighted_centroid(image, region_mask, threshold=0.0): + values = image[region_mask] + weights = np.clip(values - threshold, 0.0, None) + if weights.sum() <= 1e-15: + weights = values.copy() + if weights.sum() <= 1e-15: + iz, ix = np.argwhere(region_mask)[0] + return x_grid_bp[ix], z_grid_bp[iz] + + x_vals = XX_bp[region_mask] + z_vals = ZZ_bp[region_mask] + return (x_vals * weights).sum() / weights.sum(), (z_vals * weights).sum() / weights.sum() + + + +def compact_peak_centroid(image, iz, ix, peak): + x0 = x_grid_bp[ix] + z0 = z_grid_bp[iz] + window_mask = ( + (np.abs(XX_bp - x0) <= BP_CENTER_RX_CM / 100.0) & + (np.abs(ZZ_bp - z0) <= BP_CENTER_RZ_CM / 100.0) + ) + threshold = BP_CENTER_THRESH_FRAC * peak + center_mask = window_mask & (image >= threshold) + + if center_mask.sum() == 0: + center_mask = window_mask.copy() + center_mask[iz, ix] = True + + values = image[center_mask] + weights = np.clip(values - threshold, 0.0, None) ** BP_CENTER_WEIGHT_POWER + if weights.sum() <= 1e-15: + weights = values.copy() + if weights.sum() <= 1e-15: + return x0, z0, center_mask + + x_vals = XX_bp[center_mask] + z_vals = ZZ_bp[center_mask] + x_c = (x_vals * weights).sum() / weights.sum() + z_c = (z_vals * weights).sum() / weights.sum() + return x_c, z_c, center_mask + +def find_bp_objects(bp_image): + """ + CLEAN-подобный поиск объектов на BP-карте. + + Для каждого шага берется максимум рабочей карты, вокруг него выделяется + связная область выше BP_REGION_THRESH_FRAC от локального пика, затем + считается взвешенный центроид этой области. После этого более широкая + область вокруг той же цели подавляется на рабочей карте. + """ + work = bp_image.copy() + objects = [] + global_peak = float(work.max()) + stop_level = BP_OBJECT_MIN_FRAC * global_peak + dx_cm = abs(x_grid_bp[1] - x_grid_bp[0]) * 100 + dz_cm = abs(z_grid_bp[1] - z_grid_bp[0]) * 100 + pixel_area_cm2 = dx_cm * dz_cm + + for step in range(MAX_OBJECTS): + peak = float(work.max()) + if peak <= stop_level or peak <= 0: + break + + iz, ix = np.unravel_index(np.argmax(work), work.shape) + x_peak_local = x_grid_bp[ix] + z_peak_local = z_grid_bp[iz] + + region_threshold = BP_REGION_THRESH_FRAC * peak + region_mask = component_containing_peak(work, iz, ix, region_threshold) + region_area_cm2 = float(region_mask.sum() * pixel_area_cm2) + + if region_area_cm2 < BP_MIN_REGION_AREA_CM2: + work[iz, ix] = 0.0 + continue + + x_region_centroid, z_region_centroid = weighted_centroid(work, region_mask, threshold=region_threshold) + if BP_CENTER_USE_COMPACT: + x_centroid, z_centroid, center_mask = compact_peak_centroid(work, iz, ix, peak) + else: + x_centroid, z_centroid = x_region_centroid, z_region_centroid + center_mask = region_mask.copy() + + center_area_cm2 = float(center_mask.sum() * pixel_area_cm2) + region_values = work[region_mask] + objects.append({ + 'index': len(objects) + 1, + 'ix_peak': int(ix), + 'iz_peak': int(iz), + 'x_peak': float(x_peak_local), + 'z_peak': float(z_peak_local), + 'x': float(x_centroid), + 'z': float(z_centroid), + 'x_region': float(x_region_centroid), + 'z_region': float(z_region_centroid), + 'peak': peak, + 'area_cm2': region_area_cm2, + 'center_area_cm2': center_area_cm2, + 'region_mask': region_mask.copy(), + 'center_mask': center_mask.copy(), + 'mean_value': float(region_values.mean()), + 'sum_value': float(region_values.sum()), + }) + + if BP_SUPPRESS_USE_WINDOW: + suppress_window = ( + (np.abs(XX_bp - x_peak_local) <= BP_SUPPRESS_RX_CM / 100.0) & + (np.abs(ZZ_bp - z_peak_local) <= BP_SUPPRESS_RZ_CM / 100.0) + ) + else: + suppress_window = None + + suppress_threshold = BP_SUPPRESS_THRESH_FRAC * peak + suppress_mask = component_containing_peak( + work, iz, ix, suppress_threshold, window_mask=suppress_window + ) + + # Если широкий порог дал слишком маленькую область, подавляем хотя бы область детекции. + if suppress_mask.sum() < region_mask.sum(): + suppress_mask = region_mask + + work[suppress_mask] = 0.0 + + return objects, work + + +def circular_phase_stats(bp_complex_map, mask): + if mask.sum() == 0: + return np.nan, np.nan, np.nan + + h = bp_complex_map[mask] + amp = np.abs(h) + valid = amp > 0 + if not np.any(valid): + return np.nan, np.nan, np.nan + + phase = np.angle(h[valid]) + weights = amp[valid] ** BP_PHASE_WEIGHT_POWER + if weights.sum() <= 1e-15: + weights = np.ones_like(phase) + + vec = np.sum(weights * np.exp(1j * phase)) / (np.sum(weights) + 1e-15) + phase_mean = float(np.angle(vec)) + phase_coherence = float(np.abs(vec)) + phase_circular_variance = float(1.0 - phase_coherence) + return phase_mean, phase_coherence, phase_circular_variance + + +def add_phase_metrics(objects, bp_complex_map): + for obj in objects: + mask = obj.get('center_mask', obj['region_mask']) + phase_mean, phase_coh, phase_var = circular_phase_stats(bp_complex_map, mask) + obj['phase_mean_rad'] = phase_mean + obj['phase_coherence'] = phase_coh + obj['phase_circular_variance'] = phase_var + return objects + + +def add_local_prominence_metrics(objects, bp_image): + for obj in objects: + x0 = obj['x_peak'] + z0 = obj['z_peak'] + outer_mask = ( + (np.abs(XX_bp - x0) <= BP_LOCAL_BG_RX_CM / 100.0) & + (np.abs(ZZ_bp - z0) <= BP_LOCAL_BG_RZ_CM / 100.0) & + depth_gate + ) + bg_mask = outer_mask & (~obj['region_mask']) + if bg_mask.sum() < 10: + bg_mask = depth_gate & (~obj['region_mask']) + + if bg_mask.sum() == 0: + bg_level = 0.0 + bg_p75 = 0.0 + else: + bg_values = bp_image[bg_mask] + bg_level = float(np.percentile(bg_values, BP_LOCAL_BG_PERCENTILE)) + bg_p75 = float(np.percentile(bg_values, 75)) + + peak = float(obj['peak']) + obj['local_bg'] = bg_level + obj['local_bg_p75'] = bg_p75 + obj['prominence'] = peak - bg_level + obj['contrast'] = peak / (bg_level + BP_LOCAL_CONTRAST_EPS) + return objects + + +def add_incoherent_support_metrics(objects, bp_incoherent_image, bp_cf_image=None): + for obj in objects: + if bp_incoherent_image is None: + obj['incoh_peak'] = 0.0 + obj['incoh_mean'] = 0.0 + obj['incoh_center_mean'] = 0.0 + obj['coherence_factor_peak_raw'] = 0.0 + obj['coherence_factor_center_raw'] = 0.0 + obj['coherence_factor_peak'] = 0.0 + obj['coherence_factor_center'] = 0.0 + continue + + region_mask = obj['region_mask'] + center_mask = obj.get('center_mask', region_mask) + if region_mask.sum() == 0: + obj['incoh_peak'] = 0.0 + obj['incoh_mean'] = 0.0 + else: + region_values = bp_incoherent_image[region_mask] + obj['incoh_peak'] = float(region_values.max()) + obj['incoh_mean'] = float(region_values.mean()) + + if center_mask.sum() == 0: + obj['incoh_center_mean'] = obj['incoh_mean'] + else: + obj['incoh_center_mean'] = float(bp_incoherent_image[center_mask].mean()) + + if bp_cf_image is None: + obj['coherence_factor_peak_raw'] = float( + obj.get('peak', 0.0) / (obj['incoh_peak'] + BP_SCORE_CF_EPS) + ) + obj['coherence_factor_center_raw'] = float( + obj.get('mean_value', 0.0) / (obj['incoh_center_mean'] + BP_SCORE_CF_EPS) + ) + else: + iz_peak = int(obj.get('iz_peak', 0)) + ix_peak = int(obj.get('ix_peak', 0)) + obj['coherence_factor_peak_raw'] = float(bp_cf_image[iz_peak, ix_peak]) + if center_mask.sum() == 0: + obj['coherence_factor_center_raw'] = obj['coherence_factor_peak_raw'] + else: + obj['coherence_factor_center_raw'] = float(bp_cf_image[center_mask].mean()) + + obj['coherence_factor_peak'] = float(np.clip(obj['coherence_factor_peak_raw'], 0.0, 1.0)) + obj['coherence_factor_center'] = float(np.clip(obj['coherence_factor_center_raw'], 0.0, 1.0)) + return objects + + +def _contrast_score_unit(contrast): + if not np.isfinite(contrast): + return 0.0 + if BP_SCORE_CONTRAST_CAP <= 1.0: + return 0.0 + return float(np.clip((contrast - 1.0) / (BP_SCORE_CONTRAST_CAP - 1.0), 0.0, 1.0)) + + +def add_bp_score_metrics(objects): + total_weight = ( + BP_SCORE_COH_PEAK_WEIGHT + + BP_SCORE_COHERENCE_FACTOR_WEIGHT + + BP_SCORE_PROMINENCE_WEIGHT + + BP_SCORE_CONTRAST_WEIGHT + ) + if total_weight <= 0: + total_weight = 1.0 + + for obj in objects: + coh_peak_score = float(np.clip(obj.get('peak', 0.0), 0.0, 1.0)) + coherence_factor_score = float(np.clip(obj.get('coherence_factor_peak', 0.0), 0.0, 1.0)) + prominence_score = float(np.clip(obj.get('prominence', 0.0), 0.0, 1.0)) + contrast_score = _contrast_score_unit(obj.get('contrast', np.nan)) + + score_combined = ( + BP_SCORE_COH_PEAK_WEIGHT * coh_peak_score + + BP_SCORE_COHERENCE_FACTOR_WEIGHT * coherence_factor_score + + BP_SCORE_PROMINENCE_WEIGHT * prominence_score + + BP_SCORE_CONTRAST_WEIGHT * contrast_score + ) / total_weight + + obj['score_old'] = coh_peak_score + obj['score_new'] = float(score_combined) + obj['score_coh_peak_part'] = coh_peak_score + obj['score_cf_part'] = coherence_factor_score + obj['score_prominence_part'] = prominence_score + obj['score_contrast_part'] = contrast_score + obj['score_selected'] = obj['score_new'] if BP_SCORE_MODE == 'combined' else obj['score_old'] + return objects + + +def prepare_bp_objects_for_display(objects, remove_sidelobes=True): + if remove_sidelobes: + visible = [obj for obj in objects if not obj.get('sidelobe_candidate', False)] + else: + visible = list(objects) + + if BP_SCORE_MODE == 'combined': + visible = sorted(visible, key=lambda obj: obj.get('score_new', 0.0), reverse=True) + else: + visible = sorted(visible, key=lambda obj: obj.get('score_old', obj.get('peak', 0.0)), reverse=True) + + for rank, obj in enumerate(visible, start=1): + obj['display_index'] = rank + return visible + + +def bistatic_depth_signature(x_obj, z_obj): + signature = [] + for i in range(N_tx): + for j in range(N_rx): + Rtx, Rrx = bistatic_ranges(i, j, x_obj, z_obj) + signature.append(0.5 * (Rtx + Rrx)) + return np.array(signature, dtype=float) + + +def mark_sidelobe_candidates(objects): + for obj in objects: + obj['sidelobe_candidate'] = False + obj['sidelobe_parent'] = None + obj['sidelobe_range_rms_cm'] = np.nan + obj['sidelobe_dx_cm'] = np.nan + obj['sidelobe_dz_cm'] = np.nan + + if not BP_SIDELOBE_DETECT: + return objects + + signatures = [bistatic_depth_signature(obj['x'], obj['z']) for obj in objects] + + for k, obj in enumerate(objects): + best_parent = None + best_rms_cm = np.inf + best_dx_cm = np.nan + best_dz_cm = np.nan + + for p in range(k): + parent = objects[p] + rel_peak = obj['peak'] / (parent['peak'] + 1e-12) + dx_cm = abs(obj['x'] - parent['x']) * 100.0 + dz_cm = abs(obj['z'] - parent['z']) * 100.0 + rms_cm = float(np.sqrt(np.mean((signatures[k] - signatures[p])**2)) * 100.0) + + is_candidate = ( + rel_peak <= BP_SIDELOBE_MAX_REL_PEAK and + dx_cm >= BP_SIDELOBE_MIN_DX_CM and + dz_cm <= BP_SIDELOBE_MAX_DZ_CM and + rms_cm <= BP_SIDELOBE_RANGE_RMS_TOL_CM + ) + + if is_candidate and rms_cm < best_rms_cm: + best_parent = parent + best_rms_cm = rms_cm + best_dx_cm = dx_cm + best_dz_cm = dz_cm + + if best_parent is not None: + obj['sidelobe_candidate'] = True + obj['sidelobe_parent'] = best_parent['index'] + obj['sidelobe_range_rms_cm'] = best_rms_cm + obj['sidelobe_dx_cm'] = best_dx_cm + obj['sidelobe_dz_cm'] = best_dz_cm + + return objects + + +if BP_SCORE_COMPUTE_INCOHERENT: + print('Расчет coherent + incoherent time-domain BP...', end=' ', flush=True) + bp_raw, bp_complex, bp_incoh_raw, bp_cf_map = backproject_coherent_and_incoherent(H_bp, T_bp, compensate=True) + bp_incoh_map_s = normalize_bp_map(bp_incoh_raw) +else: + print('Расчет coherent time-domain BP...', end=' ', flush=True) + bp_raw, bp_complex = backproject_coherent(H_bp, T_bp, compensate=True) + bp_incoh_raw = None + bp_incoh_map_s = None + bp_cf_map = None +bp_map_s = normalize_bp_map(bp_raw) +print('готово.') +print(f'BP score mode: {BP_SCORE_MODE} (peak=старый, combined=экспериментальный)') + +if BP_VALIDATE_COMPENSATION: + print('Расчет coherent BP без compensation для валидации...', end=' ', flush=True) + bp_raw_nocomp, bp_complex_nocomp = backproject_coherent(H_bp, T_bp, compensate=False) + bp_map_nocomp_s = normalize_bp_map(bp_raw_nocomp) + print('готово.') +else: + bp_raw_nocomp = None + bp_complex_nocomp = None + bp_map_nocomp_s = None + +bp_objects_all, bp_residual = find_bp_objects(bp_map_s) +bp_objects_all = add_phase_metrics(bp_objects_all, bp_complex) +bp_objects_all = add_local_prominence_metrics(bp_objects_all, bp_map_s) +bp_objects_all = add_incoherent_support_metrics(bp_objects_all, bp_incoh_map_s, bp_cf_map) +bp_objects_all = mark_sidelobe_candidates(bp_objects_all) +bp_objects_all = add_bp_score_metrics(bp_objects_all) + +bp_map_display = bp_map_s.copy() +if BP_REMOVE_SIDELOBE_OBJECTS: + for obj in bp_objects_all: + if obj['sidelobe_candidate']: + bp_map_display[obj['region_mask']] = 0.0 +bp_objects = prepare_bp_objects_for_display(bp_objects_all, remove_sidelobes=BP_REMOVE_SIDELOBE_OBJECTS) +bp_detected_object_count = len(bp_objects) +if bp_detected_object_count > BP_MAX_DETECTED_OBJECTS_TO_DRAW: + bp_objects_to_plot = [] +else: + bp_objects_to_plot = bp_objects[:BP_DRAW_TOP_M_OBJECTS] + +iz_max, ix_max = np.unravel_index(np.argmax(bp_map_s), bp_map_s.shape) +x_peak = x_grid_bp[ix_max] +z_peak = z_grid_bp[iz_max] +peak_value = bp_map_s[iz_max, ix_max] +main_obj = bp_objects[0] if bp_objects else None + +print('\n' + '=' * 72) +print(' COHERENT TIME-DOMAIN BP: максимум карты и выбранный объект') +print('=' * 72) +print(f' argmax: x = {x_peak*100:+.1f} см, z = {z_peak*100:.1f} см, BP = {peak_value:.3f}') +if main_obj is not None: + print(f" selected: x = {main_obj['x']*100:+.1f} см, z = {main_obj['z']*100:.1f} см, " + f"score = {main_obj['score_selected']:.3f}, area = {main_obj['area_cm2']:.1f} см^2") +print('=' * 72) + +print('\n' + '=' * 72) +n_sl_all = sum(obj['sidelobe_candidate'] for obj in bp_objects_all) +if BP_REMOVE_SIDELOBE_OBJECTS: + print(f' НАЙДЕННЫЕ ОБЪЕКТЫ НА COHERENT BP-КАРТЕ, max {MAX_OBJECTS} ' + f'(SL скрыты: {n_sl_all})') +else: + print(f' НАЙДЕННЫЕ ОБЪЕКТЫ НА COHERENT BP-КАРТЕ, max {MAX_OBJECTS} ' + f'(SL показаны: {n_sl_all})') +print('=' * 166) +print(f" {'#':<4} {'Det#':>5} {'Xc [см]':>10} {'Zc [см]':>10} {'Xmax [см]':>11} {'Zmax [см]':>11} " + f"{'Peak':>7} {'Incoh':>7} {'CF':>6} {'Prom':>7} {'Contr':>7} {'Score':>7} {'NewSc':>7} " + f"{'Area [см2]':>11} {'PhCoh':>7} {'SL?':>5} {'Parent':>6} {'RMSr [см]':>10}") +print('-' * 166) +for obj in bp_objects_to_plot: + sl_label = 'yes' if obj['sidelobe_candidate'] else 'no' + parent_label = '-' if obj['sidelobe_parent'] is None else str(obj['sidelobe_parent']) + rms_label = '-' if np.isnan(obj['sidelobe_range_rms_cm']) else f"{obj['sidelobe_range_rms_cm']:.1f}" + print(f" {obj['display_index']:<4} {obj['index']:>5} {obj['x']*100:>+10.1f} {obj['z']*100:>10.1f} " + f"{obj['x_peak']*100:>+11.1f} {obj['z_peak']*100:>11.1f} " + f"{obj['peak']:>7.3f} {obj['incoh_peak']:>7.3f} {obj['coherence_factor_peak']:>6.3f} " + f"{obj['prominence']:>7.3f} {obj['contrast']:>7.2f} " + f"{obj['score_selected']:>7.3f} {obj['score_new']:>7.3f} " + f"{obj['area_cm2']:>11.1f} {obj['phase_coherence']:>7.3f} " + f"{sl_label:>5} {parent_label:>6} {rms_label:>10}") +print('=' * 166) + + +# ══════════════════════════════════════════════════════ +# 4. ТОЛЬКО BP-КАРТА +# ══════════════════════════════════════════════════════ + +if BP_ONLY_MAP_OUTPUT: + _bp_stdout_redirect.__exit__(None, None, None) + +fig, ax = plt.subplots(figsize=(12, 7)) +im = ax.imshow( + bp_map_display, + extent=[x_grid_bp[0]*100, x_grid_bp[-1]*100, z_grid_bp[-1]*100, z_grid_bp[0]*100], + aspect='auto', + cmap='jet', + vmin=0.25, + vmax=0.95, +) +plt.colorbar(im, ax=ax, label='Нормированная |coherent BP|') +ax.plot(x_tx * 100, z_tx * 100, 'r^', ms=12, label='Tx', zorder=5) +ax.plot(x_rx * 100, z_rx * 100, 'bv', ms=12, label='Rx', zorder=5) + +for obj in bp_objects_to_plot: + ax.contour( + x_grid_bp * 100, + z_grid_bp * 100, + obj['region_mask'].astype(float), + levels=[0.5], + colors='white', + linewidths=0.9, + alpha=0.75, + ) + ax.contour( + x_grid_bp * 100, + z_grid_bp * 100, + obj['center_mask'].astype(float), + levels=[0.5], + colors='cyan', + linewidths=0.8, + alpha=0.85, + ) + if obj['sidelobe_candidate']: + ax.plot(obj['x'] * 100, obj['z'] * 100, 'x', color='yellow', ms=9, + mew=2.0, zorder=7) + label_text = f"{obj['index']} SL" + text_color = 'yellow' + else: + ax.plot(obj['x'] * 100, obj['z'] * 100, 'wo', ms=7, + markeredgecolor='k', mew=0.8, zorder=6) + label_text = str(obj.get('display_index', obj['index'])) + text_color = 'white' + ax.text(obj['x'] * 100 + 3, obj['z'] * 100, label_text, + color=text_color, fontsize=9, weight='bold', zorder=7) + +if len(bp_objects_to_plot) > 0: + ax.plot([], [], 'wo', ms=7, markeredgecolor='k', mew=0.8, label='Центроид области') + if not BP_REMOVE_SIDELOBE_OBJECTS: + ax.plot([], [], 'x', color='yellow', ms=9, mew=2.0, label='Sidelobe candidate') + +ax.axhline(MIN_DEPTH * 100, color='white', lw=1.0, ls='--', alpha=0.75) +ax.set_xlabel('X [см]') +ax.set_ylabel('Глубина Z [см]') +map_title = f'Time-domain coherent BackProjection | score={BP_SCORE_MODE}, shown={len(bp_objects_to_plot)}/{bp_detected_object_count}' +if BP_REMOVE_SIDELOBE_OBJECTS: + map_title += ' (SL области скрыты на карте)' +ax.set_title(map_title) +ax.set_xlim(x_grid_bp[0] * 100, x_grid_bp[-1] * 100) +ax.set_ylim(z_grid_bp[-1] * 100, 0) +ax.legend(loc='lower right', fontsize=9) +ax.grid(alpha=0.22) +ax.invert_yaxis() +plt.tight_layout() +plt.show() diff --git a/data_acq_and_processing/common_cpp/config/include/run_config.hpp b/data_acq_and_processing/common_cpp/config/include/run_config.hpp index 9a23ccd..4326d29 100644 --- a/data_acq_and_processing/common_cpp/config/include/run_config.hpp +++ b/data_acq_and_processing/common_cpp/config/include/run_config.hpp @@ -116,15 +116,20 @@ struct PreprocessConfig { }; struct GprTxGeometry { - // Transmitter geometry keyed by output switch position. + // Transmitter geometry keyed by output switch position. y_m/z_m default to 0 so + // legacy 1D configs continue to render in the y=0, z=0 plane. std::uint32_t output_pos = 0; float x_m = 0.0F; + float y_m = 0.0F; + float z_m = 0.0F; }; struct GprRxGeometry { - // Receiver geometry keyed by input switch position. + // Receiver geometry keyed by input switch position. y_m/z_m default to 0. std::uint32_t input_pos = 0; float x_m = 0.0F; + float y_m = 0.0F; + float z_m = 0.0F; }; struct GprConfig { diff --git a/data_acq_and_processing/common_cpp/config/src/run_config.cpp b/data_acq_and_processing/common_cpp/config/src/run_config.cpp index c26b0be..44a3488 100644 --- a/data_acq_and_processing/common_cpp/config/src/run_config.cpp +++ b/data_acq_and_processing/common_cpp/config/src/run_config.cpp @@ -309,6 +309,8 @@ void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) { GprTxGeometry entry{}; entry.output_pos = optional_u32(*entry_obj, "output_pos", 0U); entry.x_m = optional_f32(*entry_obj, "x_m", 0.0F); + entry.y_m = optional_f32(*entry_obj, "y_m", 0.0F); + entry.z_m = optional_f32(*entry_obj, "z_m", 0.0F); config.tx_geometry.push_back(std::move(entry)); } } @@ -321,6 +323,8 @@ void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) { GprRxGeometry entry{}; entry.input_pos = optional_u32(*entry_obj, "input_pos", 0U); entry.x_m = optional_f32(*entry_obj, "x_m", 0.0F); + entry.y_m = optional_f32(*entry_obj, "y_m", 0.0F); + entry.z_m = optional_f32(*entry_obj, "z_m", 0.0F); config.rx_geometry.push_back(std::move(entry)); } } diff --git a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp index 6f795a5..25782c1 100644 --- a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp +++ b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp @@ -49,6 +49,9 @@ struct ProcessingLiveConfig { bool gpr_background_subtract_enabled = true; std::uint32_t gpr_background_mean_count = 10U; bool gpr_remove_sidelobe_objects_enabled = true; + // BP image is computed in the y=imaging_plane_y_m slice of the 3D grid. + // Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane. + float gpr_imaging_plane_y_m = 0.0F; bool reprocess_current_result = true; std::uint64_t history_command_seq = 0; HistoryCommand history_command = HistoryCommand::None; diff --git a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp index 09b6c5c..e1fd4dd 100644 --- a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp +++ b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp @@ -305,6 +305,12 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_remove_sidelobe_objects_enabled = found->get(); } + if (const auto found = root.find("gpr_imaging_plane_y_m"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_imaging_plane_y_m must be number"); + } + config.gpr_imaging_plane_y_m = static_cast(found->get()); + } if (const auto found = root.find("reprocess_current_result"); found != root.end()) { if (!found->is_boolean()) { throw std::runtime_error("processing.reprocess_current_result must be bool"); diff --git a/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp index e3c22b3..3bef4e9 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp +++ b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp @@ -49,10 +49,16 @@ constexpr double kScoreCfEps = 1e-12; using PairKey = std::uint64_t; struct GeometrySelection { + // Per-local-index Tx/Rx antenna coordinates in metres. y_/z_ default to 0 + // for legacy configs so the imaging plane coincides with the antennas. std::vector input_positions{}; std::vector output_positions{}; std::vector x_tx{}; + std::vector y_tx{}; + std::vector z_tx{}; std::vector x_rx{}; + std::vector y_rx{}; + std::vector z_rx{}; std::unordered_map input_local_by_pos{}; std::unordered_map output_local_by_pos{}; }; @@ -365,29 +371,43 @@ void normalize_in_place(std::vector& values) { return result; } +struct AntennaXYZ { + double x_m = 0.0; + double y_m = 0.0; + double z_m = 0.0; +}; + [[nodiscard]] auto build_geometry_selection( const config::RunConfig& run_config, const ProcessingLiveConfig& live_config ) -> GeometrySelection { - std::unordered_map tx_x_by_pos{}; + std::unordered_map tx_by_pos{}; for (const auto& entry : run_config.gpr.tx_geometry) { - tx_x_by_pos[entry.output_pos] = static_cast(entry.x_m); + tx_by_pos[entry.output_pos] = AntennaXYZ{ + static_cast(entry.x_m), + static_cast(entry.y_m), + static_cast(entry.z_m), + }; } - std::unordered_map rx_x_by_pos{}; + std::unordered_map rx_by_pos{}; for (const auto& entry : run_config.gpr.rx_geometry) { - rx_x_by_pos[entry.input_pos] = static_cast(entry.x_m); + rx_by_pos[entry.input_pos] = AntennaXYZ{ + static_cast(entry.x_m), + static_cast(entry.y_m), + static_cast(entry.z_m), + }; } std::vector available_outputs{}; - available_outputs.reserve(tx_x_by_pos.size()); - for (const auto& [position, _] : tx_x_by_pos) { + available_outputs.reserve(tx_by_pos.size()); + for (const auto& [position, _] : tx_by_pos) { available_outputs.push_back(position); } std::vector available_inputs{}; - available_inputs.reserve(rx_x_by_pos.size()); - for (const auto& [position, _] : rx_x_by_pos) { + available_inputs.reserve(rx_by_pos.size()); + for (const auto& [position, _] : rx_by_pos) { available_inputs.push_back(position); } @@ -395,18 +415,32 @@ void normalize_in_place(std::vector& values) { selection.output_positions = selected_positions(live_config.gpr_output_positions, std::move(available_outputs)); selection.input_positions = selected_positions(live_config.gpr_input_positions, std::move(available_inputs)); - selection.x_tx.reserve(selection.output_positions.size()); + const auto reserve_axes = [](GeometrySelection& target, std::size_t tx_count, std::size_t rx_count) { + target.x_tx.reserve(tx_count); + target.y_tx.reserve(tx_count); + target.z_tx.reserve(tx_count); + target.x_rx.reserve(rx_count); + target.y_rx.reserve(rx_count); + target.z_rx.reserve(rx_count); + }; + reserve_axes(selection, selection.output_positions.size(), selection.input_positions.size()); + for (std::size_t index = 0U; index < selection.output_positions.size(); ++index) { const auto position = selection.output_positions[index]; selection.output_local_by_pos[position] = static_cast(index); - selection.x_tx.push_back(tx_x_by_pos[position]); + const auto& xyz = tx_by_pos[position]; + selection.x_tx.push_back(xyz.x_m); + selection.y_tx.push_back(xyz.y_m); + selection.z_tx.push_back(xyz.z_m); } - selection.x_rx.reserve(selection.input_positions.size()); for (std::size_t index = 0U; index < selection.input_positions.size(); ++index) { const auto position = selection.input_positions[index]; selection.input_local_by_pos[position] = static_cast(index); - selection.x_rx.push_back(rx_x_by_pos[position]); + const auto& xyz = rx_by_pos[position]; + selection.x_rx.push_back(xyz.x_m); + selection.y_rx.push_back(xyz.y_m); + selection.z_rx.push_back(xyz.z_m); } return selection; @@ -673,19 +707,23 @@ void normalize_pair_ascans( } } +[[nodiscard]] auto distance_3d(double dx, double dy, double dz) -> double { + return std::sqrt((dx * dx) + (dy * dy) + (dz * dz)); +} + [[nodiscard]] auto build_grid( - const std::vector& x_tx, - const std::vector& x_rx, + const GeometrySelection& selection, double max_depth_m, - double min_z_m + double min_z_m, + double imaging_plane_y_m ) -> GridDefinition { GridDefinition grid{}; - if (x_tx.empty() || x_rx.empty() || !(max_depth_m > min_z_m)) { + if (selection.x_tx.empty() || selection.x_rx.empty() || !(max_depth_m > min_z_m)) { return grid; } - const auto [tx_min_it, tx_max_it] = std::minmax_element(x_tx.begin(), x_tx.end()); - const auto [rx_min_it, rx_max_it] = std::minmax_element(x_rx.begin(), x_rx.end()); + const auto [tx_min_it, tx_max_it] = std::minmax_element(selection.x_tx.begin(), selection.x_tx.end()); + const auto [rx_min_it, rx_max_it] = std::minmax_element(selection.x_rx.begin(), selection.x_rx.end()); const double x_min = std::min(*tx_min_it, *rx_min_it) - kXMarginM; const double x_max = std::max(*tx_max_it, *rx_max_it) + kXMarginM; @@ -693,8 +731,8 @@ void normalize_pair_ascans( grid.z_grid = build_axis(min_z_m, max_depth_m, kGridHeight); const std::size_t cell_count = grid.x_grid.size() * grid.z_grid.size(); - grid.tx_distance_grids.assign(x_tx.size(), std::vector(cell_count, 0.0)); - grid.rx_distance_grids.assign(x_rx.size(), std::vector(cell_count, 0.0)); + grid.tx_distance_grids.assign(selection.x_tx.size(), std::vector(cell_count, 0.0)); + grid.rx_distance_grids.assign(selection.x_rx.size(), std::vector(cell_count, 0.0)); for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { const double z_value = grid.z_grid[row]; @@ -702,13 +740,19 @@ void normalize_pair_ascans( const double x_value = grid.x_grid[col]; const auto cell_index = (row * grid.x_grid.size()) + col; - for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { - grid.tx_distance_grids[tx_index][cell_index] = - std::hypot(x_value - x_tx[tx_index], z_value); + for (std::size_t tx_index = 0U; tx_index < selection.x_tx.size(); ++tx_index) { + grid.tx_distance_grids[tx_index][cell_index] = distance_3d( + x_value - selection.x_tx[tx_index], + imaging_plane_y_m - selection.y_tx[tx_index], + z_value - selection.z_tx[tx_index] + ); } - for (std::size_t rx_index = 0U; rx_index < x_rx.size(); ++rx_index) { - grid.rx_distance_grids[rx_index][cell_index] = - std::hypot(x_value - x_rx[rx_index], z_value); + for (std::size_t rx_index = 0U; rx_index < selection.x_rx.size(); ++rx_index) { + grid.rx_distance_grids[rx_index][cell_index] = distance_3d( + x_value - selection.x_rx[rx_index], + imaging_plane_y_m - selection.y_rx[rx_index], + z_value - selection.z_rx[rx_index] + ); } } } @@ -737,37 +781,50 @@ void normalize_pair_ascans( [[nodiscard]] auto attenuation_components( double r_tx, double r_rx, - double z_m + double dz_tx, + double dz_rx ) -> std::pair { + // Antennas boresight along +Z, so cos(theta) = (z_pixel - z_antenna) / R. const double geo = 1.0 / ((r_tx * r_rx) + 1e-12); const double angle = - std::pow(z_m / (r_tx + 1e-12), 2.0) * - std::pow(z_m / (r_rx + 1e-12), 2.0); + std::pow(dz_tx / (r_tx + 1e-12), 2.0) * + std::pow(dz_rx / (r_rx + 1e-12), 2.0); return {geo + 1e-30, angle + 1e-30}; } [[nodiscard]] auto attenuation_components_at_ref_depth( std::uint32_t tx_index, std::uint32_t rx_index, - const std::vector& x_tx, - const std::vector& x_rx + const GeometrySelection& selection, + double imaging_plane_y_m ) -> std::pair { - const double x_center = 0.5 * (x_tx[tx_index] + x_rx[rx_index]); - const double r_tx = std::hypot(x_center - x_tx[tx_index], kCompensationReferenceDepthM); - const double r_rx = std::hypot(x_center - x_rx[rx_index], kCompensationReferenceDepthM); - return attenuation_components(r_tx, r_rx, kCompensationReferenceDepthM); + const double x_center = 0.5 * (selection.x_tx[tx_index] + selection.x_rx[rx_index]); + const double dz_tx = kCompensationReferenceDepthM - selection.z_tx[tx_index]; + const double dz_rx = kCompensationReferenceDepthM - selection.z_rx[rx_index]; + const double r_tx = distance_3d( + x_center - selection.x_tx[tx_index], + imaging_plane_y_m - selection.y_tx[tx_index], + dz_tx + ); + const double r_rx = distance_3d( + x_center - selection.x_rx[rx_index], + imaging_plane_y_m - selection.y_rx[rx_index], + dz_rx + ); + return attenuation_components(r_tx, r_rx, dz_tx, dz_rx); } [[nodiscard]] auto compensation_weight( double r_tx, double r_rx, - double z_m, + double dz_tx, + double dz_rx, double geo_ref, double angle_ref, double range_power, double angle_power ) -> double { - const auto [geo, angle] = attenuation_components(r_tx, r_rx, z_m); + const auto [geo, angle] = attenuation_components(r_tx, r_rx, dz_tx, dz_rx); const double geo_norm = geo / geo_ref; const double angle_norm = angle / angle_ref; @@ -788,8 +845,8 @@ void normalize_pair_ascans( const std::vector& selected_traces, const std::unordered_map& ascans_by_pair, const GridDefinition& grid, - const std::vector& x_tx, - const std::vector& x_rx, + const GeometrySelection& selection, + double imaging_plane_y_m, double velocity_mps, double min_depth_m, double max_depth_m, @@ -821,11 +878,13 @@ void normalize_pair_ascans( const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth( trace.tx_local_index, trace.rx_local_index, - x_tx, - x_rx + selection, + imaging_plane_y_m ); const auto& tx_distances = grid.tx_distance_grids[trace.tx_local_index]; const auto& rx_distances = grid.rx_distance_grids[trace.rx_local_index]; + const double z_tx_ant = selection.z_tx[trace.tx_local_index]; + const double z_rx_ant = selection.z_rx[trace.rx_local_index]; for (std::size_t row = 0U; row < height; ++row) { const double z_m = grid.z_grid[row]; @@ -833,6 +892,8 @@ void normalize_pair_ascans( if (!in_depth_gate) { continue; } + const double dz_tx = z_m - z_tx_ant; + const double dz_rx = z_m - z_rx_ant; for (std::size_t col = 0U; col < width; ++col) { const auto cell_index = (row * width) + col; @@ -847,7 +908,8 @@ void normalize_pair_ascans( const double weight = compensation_weight( r_tx, r_rx, - z_m, + dz_tx, + dz_rx, geo_ref, angle_ref, range_power, @@ -1209,14 +1271,24 @@ void apply_depth_gate( [[nodiscard]] auto bistatic_depth_signature( const ObjectRecord& object, const std::vector& selected_traces, - const std::vector& x_tx, - const std::vector& x_rx + const GeometrySelection& selection, + double imaging_plane_y_m ) -> std::vector { std::vector signature{}; signature.reserve(selected_traces.size()); for (const auto& trace : selected_traces) { - const double r_tx = std::hypot(object.x_m - x_tx[trace.tx_local_index], object.z_m); - const double r_rx = std::hypot(object.x_m - x_rx[trace.rx_local_index], object.z_m); + const auto tx = trace.tx_local_index; + const auto rx = trace.rx_local_index; + const double r_tx = distance_3d( + object.x_m - selection.x_tx[tx], + imaging_plane_y_m - selection.y_tx[tx], + object.z_m - selection.z_tx[tx] + ); + const double r_rx = distance_3d( + object.x_m - selection.x_rx[rx], + imaging_plane_y_m - selection.y_rx[rx], + object.z_m - selection.z_rx[rx] + ); signature.push_back(0.5 * (r_tx + r_rx)); } return signature; @@ -1225,13 +1297,13 @@ void apply_depth_gate( void mark_sidelobe_candidates( std::vector& objects, const std::vector& selected_traces, - const std::vector& x_tx, - const std::vector& x_rx + const GeometrySelection& selection, + double imaging_plane_y_m ) { std::vector> signatures{}; signatures.reserve(objects.size()); for (const auto& object : objects) { - signatures.push_back(bistatic_depth_signature(object, selected_traces, x_tx, x_rx)); + signatures.push_back(bistatic_depth_signature(object, selected_traces, selection, imaging_plane_y_m)); } for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) { @@ -1554,7 +1626,8 @@ void add_bp_score_metrics( } normalize_pair_ascans(ascans_by_pair, velocity_mps, min_depth_m, max_depth_m); - const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kGridZMinM); + const double imaging_plane_y_m = static_cast(live_config.gpr_imaging_plane_y_m); + const auto grid = build_grid(selection, max_depth_m, kGridZMinM, imaging_plane_y_m); if (grid.x_grid.empty() || grid.z_grid.empty()) { return results; } @@ -1563,8 +1636,8 @@ void add_bp_score_metrics( selected_traces, ascans_by_pair, grid, - selection.x_tx, - selection.x_rx, + selection, + imaging_plane_y_m, velocity_mps, min_depth_m, max_depth_m, @@ -1582,7 +1655,7 @@ void add_bp_score_metrics( auto objects = find_bp_objects(display_map, grid); add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m); add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor); - mark_sidelobe_candidates(objects, selected_traces, selection.x_tx, selection.x_rx); + mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m); add_bp_score_metrics(objects, live_config); if (live_config.gpr_remove_sidelobe_objects_enabled) { diff --git a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp index 4970af3..7600b59 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp +++ b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp @@ -678,7 +678,12 @@ void apply_legacy_motion_correction( return results; } - const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kLegacyGridZMinM); + const auto grid = build_grid( + selection, + max_depth_m, + kLegacyGridZMinM, + static_cast(live_config.gpr_imaging_plane_y_m) + ); if (grid.x_grid.empty() || grid.z_grid.empty()) { return results; } diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py index b046d6a..2346171 100644 --- a/python_app/gui/controllers/app_window_config/live_processing_mixin.py +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -88,6 +88,7 @@ class AppWindowLiveProcessingMixin: gpr_background_subtract_enabled=gpr_background_enabled, gpr_background_mean_count=gpr_background_mean_count, gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), + gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()), reprocess_current_result=bool(reprocess_current_result), history_command_seq=int(self._history_command_seq), history_command=str(history_command), @@ -213,6 +214,7 @@ class AppWindowLiveProcessingMixin: "Processing mode selected: pass_through " f"(show_magnitude={self._show_magnitude_checkbox.isChecked()}, " f"show_phase={self._show_phase_checkbox.isChecked()}, " + f"combos={self._pass_through_combo_filter_input.text().strip() or ''}, " f"fixed_y={self._pass_through_fixed_y_enabled.isChecked()}, " f"y_range={self._pass_through_y_min_db.value():g}..{self._pass_through_y_max_db.value():g} dB)" ) @@ -239,6 +241,7 @@ class AppWindowLiveProcessingMixin: f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, " f"mean_count={self._gpr_background_mean_count.value()}, " f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, " + f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, " f"render_mode={self._gpr_render_mode.currentText()}, " f"min_score={self._gpr_min_visible_score.value():g}, " f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, " diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index 1659be6..7b00e45 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -235,6 +235,7 @@ class AppWindowConfigProfileIOMixin: self._processing_mode, self._show_magnitude_checkbox, self._show_phase_checkbox, + self._pass_through_combo_filter_input, self._pass_through_fixed_y_enabled, self._pass_through_y_min_db, self._pass_through_y_max_db, @@ -262,6 +263,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_background_subtract_enabled, self._gpr_background_mean_count, self._gpr_remove_sidelobe_objects_enabled, + self._gpr_imaging_plane_y_m, self._gpr_render_mode, self._gpr_min_visible_score, self._gpr_visible_x_min_m, @@ -378,6 +380,7 @@ class AppWindowConfigProfileIOMixin: self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode) self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude)) self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase)) + self._pass_through_combo_filter_input.setText(str(gui_state.processing.pass_through.combo_filter)) self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled)) self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db)) self._pass_through_y_max_db.setValue(float(gui_state.processing.pass_through.y_max_db)) @@ -423,6 +426,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_remove_sidelobe_objects_enabled.setChecked( bool(gui_state.processing.gpr.remove_sidelobe_objects_enabled) ) + self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m)) self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode) self._gpr_min_visible_score.setValue(float(gui_state.processing.gpr.min_visible_score)) self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m)) diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 19fdb7a..aa485c7 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -65,41 +65,49 @@ class AppWindowConfigStateBuildersMixin: env[key] = value return env + @staticmethod + def _parse_geometry_line_coordinates(parts: list[str]) -> tuple[float, float, float]: + """Parse 1/2/3 trailing coordinate fields into (x, y, z); missing axes default to 0.""" + x_m = float(parts[0]) + y_m = float(parts[1]) if len(parts) >= 2 else 0.0 + z_m = float(parts[2]) if len(parts) >= 3 else 0.0 + return x_m, y_m, z_m + @staticmethod def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]: - """Parse line-based Tx geometry editor text.""" + """Parse line-based Tx geometry editor text: `output_pos x_m [y_m] [z_m]`.""" entries: list[GprTxGeometryModel] = [] for line_number, raw_line in enumerate(text.splitlines(), start=1): line = raw_line.strip() if not line: continue parts = line.split() - if len(parts) != 2: - raise ValueError(f"Invalid Tx geometry line {line_number}: expected `output_pos x_m`") - entries.append( - GprTxGeometryModel( - output_pos=int(parts[0]), - x_m=float(parts[1]), + if len(parts) < 2 or len(parts) > 4: + raise ValueError( + f"Invalid Tx geometry line {line_number}: expected `output_pos x_m [y_m] [z_m]`" ) + x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:]) + entries.append( + GprTxGeometryModel(output_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m) ) return entries @staticmethod def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]: - """Parse line-based Rx geometry editor text.""" + """Parse line-based Rx geometry editor text: `input_pos x_m [y_m] [z_m]`.""" entries: list[GprRxGeometryModel] = [] for line_number, raw_line in enumerate(text.splitlines(), start=1): line = raw_line.strip() if not line: continue parts = line.split() - if len(parts) != 2: - raise ValueError(f"Invalid Rx geometry line {line_number}: expected `input_pos x_m`") - entries.append( - GprRxGeometryModel( - input_pos=int(parts[0]), - x_m=float(parts[1]), + if len(parts) < 2 or len(parts) > 4: + raise ValueError( + f"Invalid Rx geometry line {line_number}: expected `input_pos x_m [y_m] [z_m]`" ) + x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:]) + entries.append( + GprRxGeometryModel(input_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m) ) return entries @@ -175,6 +183,7 @@ class AppWindowConfigStateBuildersMixin: pass_through=GuiPassThroughStateModel( show_magnitude=True, show_phase=True, + combo_filter="", fixed_y_enabled=False, y_min_db=-100.0, y_max_db=0.0, @@ -203,6 +212,7 @@ class AppWindowConfigStateBuildersMixin: background_subtract_enabled=True, background_mean_count=10, remove_sidelobe_objects_enabled=True, + imaging_plane_y_m=0.0, render_mode="heatmap", min_visible_score=0.0, visible_x_min_m=default_gpr_x_min_m, @@ -287,6 +297,7 @@ class AppWindowConfigStateBuildersMixin: pass_through=GuiPassThroughStateModel( show_magnitude=bool(self._show_magnitude_checkbox.isChecked()), show_phase=bool(self._show_phase_checkbox.isChecked()), + combo_filter=self._pass_through_combo_filter_input.text().strip(), fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()), y_min_db=float(self._pass_through_y_min_db.value()), y_max_db=float(self._pass_through_y_max_db.value()), @@ -315,6 +326,7 @@ class AppWindowConfigStateBuildersMixin: background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), background_mean_count=int(self._gpr_background_mean_count.value()), remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), + imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()), render_mode=self._gpr_render_mode.currentText(), min_visible_score=float(self._gpr_min_visible_score.value()), visible_x_min_m=float(self._gpr_visible_x_min_m.value()), diff --git a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py index 80b1dd2..40dc878 100644 --- a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py @@ -7,6 +7,7 @@ import numpy as np import pyqtgraph as pg from python_app.models.dataset_model import ResultCollection, TraceData +from python_app.models.run_config_model import parse_combos_from_text class AppWindowTracePlotMixin: @@ -26,6 +27,24 @@ class AppWindowTracePlotMixin: y_max = float(self._pass_through_y_max_db.value()) return bool(self._pass_through_fixed_y_enabled.isChecked()), min(y_min, y_max), max(y_min, y_max) + def _pass_through_combo_filter(self) -> set[tuple[int, int]] | None: + """Return selected pass-through switch combos, or `None` when all are visible.""" + text = self._pass_through_combo_filter_input.text().strip() + if not text: + return None + try: + return { + (int(combo.input), int(combo.output)) + for combo in parse_combos_from_text(text) + } + except Exception as exc: # noqa: BLE001 + self._log_warning( + "Invalid pass-through switch-combo filter.", + details=f"{exc}\nExpected format: input:output,input:output", + once_key=f"pass_through_combo_filter_invalid_{text}", + ) + return set() + def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None: """Apply pass-through magnitude-axis autorange or fixed Y window.""" fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range() @@ -91,6 +110,7 @@ class AppWindowTracePlotMixin: if not show_magnitude and not show_phase: self._clear_trace_plots() return False + combo_filter = self._pass_through_combo_filter() if show_magnitude: mag_item = magnitude_plot.getPlotItem() @@ -133,6 +153,8 @@ class AppWindowTracePlotMixin: x_max = -np.inf for block in collection.blocks: combo_key = (int(block.combo.input_pos), int(block.combo.output_pos)) + if combo_filter is not None and combo_key not in combo_filter: + continue if combo_key not in combo_colors: combo_colors[combo_key] = palette[len(combo_colors) % len(palette)] color = combo_colors[combo_key] diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 1e78a1d..f11ecc7 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -480,10 +480,10 @@ class AppWindowPreprocessMixin: try: capture_result = session.capture_current_combo() - self._record_preprocess_capture(session, capture_result) except Exception as exc: # noqa: BLE001 - self._show_exception("Failed to capture preprocess combo", exc) - self._abort_capture_sequence() + self._on_capture_combo_failed(session, exc) + return + self._record_preprocess_capture(session, capture_result) def _capture_all_remaining(self) -> None: """Capture all remaining combos for the active preprocess session.""" @@ -505,13 +505,36 @@ class AppWindowPreprocessMixin: f"{display_name} batch capture started: remaining=" f"{session.state().total_count - session.state().captured_count}" ) - try: - while not session.is_complete(): + while not session.is_complete(): + try: capture_result = session.capture_current_combo() - self._record_preprocess_capture(session, capture_result) - except Exception as exc: # noqa: BLE001 - self._show_exception("Failed to capture preprocess combo", exc) - self._abort_capture_sequence() + except Exception as exc: # noqa: BLE001 + self._on_capture_combo_failed(session, exc) + return + self._record_preprocess_capture(session, capture_result) + + def _on_capture_combo_failed( + self, + session: SequentialCaptureSession | MultiRadarSequentialCaptureSession, + exc: BaseException, + ) -> None: + """Report a failed combo capture while preserving the session and prior captures.""" + state = session.state() + combo = state.current_combo + combo_text = ( + f"input={combo.input}, output={combo.output}" if combo is not None else "" + ) + self._show_exception( + f"Failed to capture combo {combo_text}; previous captures kept, retry when ready", + exc, + ) + display_name = preprocess_asset_display_name(session.kind) + dialog = self._ensure_preprocess_dialog() + dialog.set_status( + f"{display_name} capture failed at {combo_text}: " + f"{state.captured_count}/{state.total_count} kept, ready to retry" + ) + self._update_capture_dialog_state() def _record_preprocess_capture( self, @@ -696,7 +719,11 @@ class AppWindowPreprocessMixin: next_output=next_output, can_undo=state.can_undo, can_finalize=state.is_complete, - can_capture_all=(not state.is_complete and state.current_combo is not None), + can_capture_all=( + state.supports_batch_capture + and not state.is_complete + and state.current_combo is not None + ), variant_count=state.variant_count, ) diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 7a5e4fb..e054e57 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -20,10 +20,19 @@ from PyQt6.QtWidgets import ( from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget +def _format_geometry_row(position: int, x_m: float, y_m: float, z_m: float) -> str: + """Render one geometry row, trimming trailing zero y/z so 1D layouts stay compact.""" + if z_m != 0.0: + return f"{position} {x_m:g} {y_m:g} {z_m:g}" + if y_m != 0.0: + return f"{position} {x_m:g} {y_m:g}" + return f"{position} {x_m:g}" + + def _format_tx_geometry(owner) -> str: """Render Tx geometry defaults into editable line-based text.""" return "\n".join( - f"{int(entry.output_pos)} {float(entry.x_m):g}" + _format_geometry_row(int(entry.output_pos), float(entry.x_m), float(entry.y_m), float(entry.z_m)) for entry in owner._defaults_config.gpr.tx_geometry ) @@ -31,7 +40,7 @@ def _format_tx_geometry(owner) -> str: def _format_rx_geometry(owner) -> str: """Render Rx geometry defaults into editable line-based text.""" return "\n".join( - f"{int(entry.input_pos)} {float(entry.x_m):g}" + _format_geometry_row(int(entry.input_pos), float(entry.x_m), float(entry.y_m), float(entry.z_m)) for entry in owner._defaults_config.gpr.rx_geometry ) @@ -71,6 +80,9 @@ def build_processing_group(owner) -> QGroupBox: owner._show_phase_checkbox = QCheckBox("Show phase") owner._show_phase_checkbox.setChecked(bool(pass_defaults.show_phase)) + owner._pass_through_combo_filter_input = QLineEdit(str(pass_defaults.combo_filter)) + owner._pass_through_combo_filter_input.setPlaceholderText("empty = all, e.g. 0:0,1:0") + owner._pass_through_fixed_y_enabled = QCheckBox("Fix magnitude Y range") owner._pass_through_fixed_y_enabled.setChecked(bool(pass_defaults.fixed_y_enabled)) @@ -93,11 +105,12 @@ def build_processing_group(owner) -> QGroupBox: [ owner._show_magnitude_checkbox, owner._show_phase_checkbox, + ("Switch combos", owner._pass_through_combo_filter_input), owner._pass_through_fixed_y_enabled, ("Y min dB", owner._pass_through_y_min_db), ("Y max dB", owner._pass_through_y_max_db), ], - split_index=3, + split_index=4, ) owner._processing_mode_pages.addWidget(pass_through_page) @@ -162,11 +175,11 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity)) owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner)) - owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m") + owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m [y_m] [z_m]") owner._gpr_tx_geometry_input.setFixedHeight(78) owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner)) - owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m") + owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m [y_m] [z_m]") owner._gpr_rx_geometry_input.setFixedHeight(78) owner._gpr_common_page = _build_processing_mode_page( @@ -277,6 +290,15 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_visible_z_max_m.setSingleStep(0.1) owner._gpr_visible_z_max_m.setValue(float(gpr_live_defaults.visible_z_max_m)) + owner._gpr_imaging_plane_y_m = QDoubleSpinBox() + owner._gpr_imaging_plane_y_m.setDecimals(3) + owner._gpr_imaging_plane_y_m.setRange(-50.0, 50.0) + owner._gpr_imaging_plane_y_m.setSingleStep(0.05) + owner._gpr_imaging_plane_y_m.setValue(float(gpr_live_defaults.imaging_plane_y_m)) + owner._gpr_imaging_plane_y_m.setToolTip( + "Y coordinate of the BP imaging slice (m). Use 0 for legacy 1D antenna layouts." + ) + gpr_page = _build_processing_mode_page( owner._processing_mode_pages, [ @@ -293,6 +315,7 @@ def build_processing_group(owner) -> QGroupBox: ("Draw top M objects", owner._gpr_draw_top_m_objects), ("Start MHz", owner._gpr_start_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz), + ("Imaging plane Y m", owner._gpr_imaging_plane_y_m), ("Visible X min m", owner._gpr_visible_x_min_m), ("Visible X max m", owner._gpr_visible_x_max_m), ("Visible Z min m", owner._gpr_visible_z_min_m), @@ -445,6 +468,7 @@ def build_processing_group(owner) -> QGroupBox: owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed) owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed) owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed) + owner._pass_through_combo_filter_input.editingFinished.connect(owner._on_trace_visibility_changed) owner._pass_through_fixed_y_enabled.toggled.connect(owner._sync_pass_through_y_controls) owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._pass_through_y_min_db.valueChanged.connect(owner._on_processing_live_settings_changed) @@ -472,6 +496,7 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed) + owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed) owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed) owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed) diff --git a/python_app/gui/controllers/sections/switch_section.py b/python_app/gui/controllers/sections/switch_section.py index e2349f8..657645c 100644 --- a/python_app/gui/controllers/sections/switch_section.py +++ b/python_app/gui/controllers/sections/switch_section.py @@ -49,10 +49,10 @@ def build_switch_group(owner) -> QGroupBox: single_row = QHBoxLayout() single_row.setSpacing(8) single_row.addWidget(QLabel("Single combo")) - single_row.addWidget(QLabel("Output")) - single_row.addWidget(owner._single_combo_output) single_row.addWidget(QLabel("Input")) single_row.addWidget(owner._single_combo_input) + single_row.addWidget(QLabel("Output")) + single_row.addWidget(owner._single_combo_output) single_row.addWidget(owner._single_combo_select_button) layout.addLayout(single_row) diff --git a/python_app/hardware_full/librevna_multi_device_driver/controller.py b/python_app/hardware_full/librevna_multi_device_driver/controller.py index be6a7cb..8b2257c 100644 --- a/python_app/hardware_full/librevna_multi_device_driver/controller.py +++ b/python_app/hardware_full/librevna_multi_device_driver/controller.py @@ -93,6 +93,14 @@ class MultiDeviceVnaController: if not self._reference_configuration_applied: self._configure_reference_clocks() + # Even when the device-side configuration matches and we skip reconfiguration, + # the host-side packet queue has been accumulating datapoints from cycles that + # ran between calls. Draining here guarantees the next collect_running_sweep_cycles + # returns a freshly-arriving cycle (the cycle tracker waits for point_index==0). + # Without this drain, callers would receive whichever stale cycle happened to be + # at the head of the queue — e.g. data from before a manual cable swap. + self._drain_all_received_packets() + if ( self._sweep_is_running and self._last_applied_sweep_configuration == sweep_configuration @@ -104,7 +112,6 @@ class MultiDeviceVnaController: self._send_idle_to_all_devices() time.sleep(self._reconfigure_delay_s) - self._drain_all_received_packets() self._configure_sweep_on_all_devices( sweep_configuration, master_stimulus_ports=stimulus_ports, diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index 627c78a..7df9820 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -180,6 +180,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.pass_through.show_phase, "gui.processing.pass_through", ), + combo_filter=_optional_string( + pass_through_object, + "combo_filter", + gui.processing.pass_through.combo_filter, + "gui.processing.pass_through", + ), fixed_y_enabled=_optional_bool( pass_through_object, "fixed_y_enabled", @@ -313,6 +319,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.remove_sidelobe_objects_enabled, "gui.processing.gpr", ), + imaging_plane_y_m=_optional_float( + gpr_object, + "imaging_plane_y_m", + gui.processing.gpr.imaging_plane_y_m, + "gui.processing.gpr", + ), render_mode=_optional_string( gpr_object, "render_mode", @@ -482,6 +494,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "pass_through": { "show_magnitude": gui.processing.pass_through.show_magnitude, "show_phase": gui.processing.pass_through.show_phase, + "combo_filter": gui.processing.pass_through.combo_filter, "fixed_y_enabled": gui.processing.pass_through.fixed_y_enabled, "y_min_db": gui.processing.pass_through.y_min_db, "y_max_db": gui.processing.pass_through.y_max_db, @@ -510,6 +523,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, "background_mean_count": gui.processing.gpr.background_mean_count, "remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled, + "imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m, "render_mode": gui.processing.gpr.render_mode, "min_visible_score": gui.processing.gpr.min_visible_score, "visible_x_min_m": gui.processing.gpr.visible_x_min_m, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index ef157e8..0d5ab2b 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -27,6 +27,7 @@ class GuiPassThroughStateModel: show_magnitude: bool = True show_phase: bool = True + combo_filter: str = "" fixed_y_enabled: bool = False y_min_db: float = -100.0 y_max_db: float = 0.0 @@ -63,6 +64,7 @@ class GuiGprStateModel: background_subtract_enabled: bool = True background_mean_count: int = 10 remove_sidelobe_objects_enabled: bool = True + imaging_plane_y_m: float = 0.0 render_mode: str = "heatmap" min_visible_score: float = 0.0 visible_x_min_m: float = -2.0 diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 218d26c..3008f1a 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -327,6 +327,8 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: GprTxGeometryModel( output_pos=int(entry_payload.get("output_pos", 0)), x_m=float(entry_payload.get("x_m", 0.0)), + y_m=float(entry_payload.get("y_m", 0.0)), + z_m=float(entry_payload.get("z_m", 0.0)), ) ) model.gpr.rx_geometry = [] @@ -338,6 +340,8 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: GprRxGeometryModel( input_pos=int(entry_payload.get("input_pos", 0)), x_m=float(entry_payload.get("x_m", 0.0)), + y_m=float(entry_payload.get("y_m", 0.0)), + z_m=float(entry_payload.get("z_m", 0.0)), ) ) model.apply_device_model_constraints() @@ -519,6 +523,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: { "output_pos": entry.output_pos, "x_m": entry.x_m, + "y_m": entry.y_m, + "z_m": entry.z_m, } for entry in model.gpr.tx_geometry ], @@ -526,6 +532,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: { "input_pos": entry.input_pos, "x_m": entry.x_m, + "y_m": entry.y_m, + "z_m": entry.z_m, } for entry in model.gpr.rx_geometry ], diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 51a3f58..b6e4f14 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -223,10 +223,15 @@ class PreprocessModel: @dataclass(slots=True) class GprTxGeometryModel: - """One transmitter geometry record keyed by output switch position.""" + """One transmitter geometry record keyed by output switch position. + + y_m / z_m default to 0 so 1D antenna layouts keep their pre-3D semantics. + """ output_pos: int = 0 x_m: float = 0.0 + y_m: float = 0.0 + z_m: float = 0.0 @dataclass(slots=True) @@ -235,6 +240,8 @@ class GprRxGeometryModel: input_pos: int = 0 x_m: float = 0.0 + y_m: float = 0.0 + z_m: float = 0.0 @dataclass(slots=True) diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 6a4b765..79621f1 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -43,6 +43,7 @@ class ProcessingLiveConfig: gpr_background_subtract_enabled: bool = True gpr_background_mean_count: int = 10 gpr_remove_sidelobe_objects_enabled: bool = True + gpr_imaging_plane_y_m: float = 0.0 reprocess_current_result: bool = True history_command_seq: int = 0 history_command: str = "none" @@ -93,6 +94,7 @@ class ProcessingLiveConfig: "gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled), "gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), + "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), "reprocess_current_result": bool(self.reprocess_current_result), "history_command_seq": int(self.history_command_seq), "history_command": str(self.history_command), diff --git a/python_app/tests/test_gui_profile_codec.py b/python_app/tests/test_gui_profile_codec.py new file mode 100644 index 0000000..e29f11c --- /dev/null +++ b/python_app/tests/test_gui_profile_codec.py @@ -0,0 +1,35 @@ +"""Tests for GUI profile persistence.""" + +from __future__ import annotations + +import unittest + +from python_app.models.gui_profile_model import ( + GuiPassThroughStateModel, + GuiProcessingStateModel, + GuiProfileModel, + GuiStateModel, +) + + +class GuiProfileCodecTest(unittest.TestCase): + def test_pass_through_combo_filter_round_trips(self) -> None: + profile = GuiProfileModel( + gui=GuiStateModel( + processing=GuiProcessingStateModel( + pass_through=GuiPassThroughStateModel(combo_filter="0:0,1:0") + ) + ) + ) + + encoded = profile.to_dict() + decoded = GuiProfileModel.from_dict(encoded) + + self.assertIsNotNone(decoded.gui) + assert decoded.gui is not None + self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0") + self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0") + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index ffeae75..b3f2e68 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -176,6 +176,7 @@ class MultiRadarSequentialCaptureSession: can_undo=bool(self._captured_batches), is_complete=self.is_complete(), variant_count=len(self._radar_variants), + supports_batch_capture=not self._manual_multi_device_capture, ) def capture_current_combo(self) -> MultiRadarCaptureBatch: @@ -186,9 +187,11 @@ class MultiRadarSequentialCaptureSession: if combo is None: raise RuntimeError("Capture session is already complete") + pending_traces_by_radar_key: dict[str, list[TraceData]] = {} + display_traces: list[TraceData] = [] + variant_labels: list[str] = [] + if self._is_multi_device: - traces: list[TraceData] = [] - variant_labels: list[str] = [] for variant in self._radar_variants: self._radar.configure(variant.config.radar.sweep) if self._base_config.runtime.settling_ms > 0: @@ -198,56 +201,47 @@ class MultiRadarSequentialCaptureSession: raise RuntimeError(f"Multi-device variant {variant.display_name} returned no traces") if self._manual_multi_device_capture: trace = select_trace_for_combo(collection, combo) - self._traces_by_radar_key[variant.radar_key].append(trace) - traces.append(trace) + pending_traces_by_radar_key[variant.radar_key] = [trace] + display_traces.append(trace) else: - self._traces_by_radar_key[variant.radar_key].extend(collection.traces) - traces.append(collection.traces[-1]) + pending_traces_by_radar_key[variant.radar_key] = list(collection.traces) + display_traces.append(collection.traces[-1]) variant_labels.append(variant.display_name) - - batch = MultiRadarCaptureBatch( - combo=combo, - traces=tuple(traces), - variant_labels=tuple(variant_labels), - ) - self._captured_batches.append(batch) - if self._manual_multi_device_capture: - self._next_index += 1 - else: - self._next_index = len(self._combos) - return batch - - assert self._input_switch is not None - assert self._output_switch is not None - self._output_switch.switch_to(combo.output) - self._input_switch.switch_to(combo.input) - if self._base_config.runtime.settling_ms > 0: - time.sleep(self._base_config.runtime.settling_ms / 1000.0) - - traces: list[TraceData] = [] - variant_labels: list[str] = [] - for variant in self._radar_variants: - self._radar.configure(variant.config.radar.sweep) + else: + assert self._input_switch is not None + assert self._output_switch is not None + self._output_switch.switch_to(combo.output) + self._input_switch.switch_to(combo.input) if self._base_config.runtime.settling_ms > 0: time.sleep(self._base_config.runtime.settling_ms / 1000.0) - sweep = self._radar.acquire() - trace = TraceData( - combo=ComboKey(input_pos=combo.input, output_pos=combo.output), - frequency_hz=np.asarray(sweep.x, dtype=np.float32), - s11=np.asarray(sweep.trace("s11"), dtype=np.complex64), - s21=np.asarray(sweep.trace("s21"), dtype=np.complex64), - ) - traces.append(trace) - variant_labels.append(variant.display_name) - self._traces_by_radar_key[variant.radar_key].append(trace) + for variant in self._radar_variants: + self._radar.configure(variant.config.radar.sweep) + if self._base_config.runtime.settling_ms > 0: + time.sleep(self._base_config.runtime.settling_ms / 1000.0) + sweep = self._radar.acquire() + trace = TraceData( + combo=ComboKey(input_pos=combo.input, output_pos=combo.output), + frequency_hz=np.asarray(sweep.x, dtype=np.float32), + s11=np.asarray(sweep.trace("s11"), dtype=np.complex64), + s21=np.asarray(sweep.trace("s21"), dtype=np.complex64), + ) + pending_traces_by_radar_key[variant.radar_key] = [trace] + display_traces.append(trace) + variant_labels.append(variant.display_name) + + for radar_key, traces in pending_traces_by_radar_key.items(): + self._traces_by_radar_key[radar_key].extend(traces) batch = MultiRadarCaptureBatch( combo=combo, - traces=tuple(traces), + traces=tuple(display_traces), variant_labels=tuple(variant_labels), ) self._captured_batches.append(batch) - self._next_index += 1 + if self._is_multi_device and not self._manual_multi_device_capture: + self._next_index = len(self._combos) + else: + self._next_index += 1 return batch def undo_last_capture(self) -> MultiRadarCaptureBatch: diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index a499e42..3d6caf4 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -30,6 +30,7 @@ class SequentialCaptureState: can_undo: bool is_complete: bool variant_count: int = 1 + supports_batch_capture: bool = True class SequentialCaptureSession: @@ -141,6 +142,7 @@ class SequentialCaptureSession: current_combo=current_combo, can_undo=bool(self._traces), is_complete=self.is_complete(), + supports_batch_capture=not self._manual_multi_device_capture, ) def capture_current_combo(self) -> TraceData: @@ -153,6 +155,8 @@ class SequentialCaptureSession: if self._is_multi_device: collection = self._radar.acquire_collection(collection_id=1) + if not collection.traces: + raise RuntimeError("Multi-device capture returned no traces") if self._manual_multi_device_capture: trace = select_trace_for_combo(collection, combo) self._traces.append(trace) @@ -161,8 +165,6 @@ class SequentialCaptureSession: self._traces.extend(collection.traces) self._next_index = len(self._combos) - if not collection.traces: - raise RuntimeError("Multi-device capture returned no traces") return collection.traces[-1] assert self._input_switch is not None