diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 05139b2..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "permissions": { - "allow": [ - "PowerShell(Get-Command pdftotext, pdftoppm, gswin64c -ErrorAction SilentlyContinue)", - "PowerShell(Get-Command pdftotext -ErrorAction SilentlyContinue)", - "PowerShell(Get-Command python -ErrorAction SilentlyContinue)", - "PowerShell(Write-Output \"py:\"; \\(Get-Command py -ErrorAction SilentlyContinue\\).Source; Write-Output \"python:\"; \\(Get-Command python -ErrorAction SilentlyContinue\\).Source; Write-Output \"pdftotext:\"; \\(Get-Command pdftotext -ErrorAction SilentlyContinue\\).Source)", - "PowerShell(py -c \"import pypdf; print\\(pypdf.__version__\\)\" 2>&1)", - "PowerShell(py -c \"import pdfplumber; print\\(pdfplumber.__version__\\)\" 2>&1)", - "PowerShell(py -c \"import fitz; print\\(fitz.__version__\\)\" 2>&1)", - "PowerShell(py -m pip install --quiet pymupdf 2>&1)" - ] - } -} diff --git a/.gitignore b/.gitignore index 2be5523..9bafc13 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml -python_app/runtime \ No newline at end of file +python_app/runtime +SHARE_INTERNET_TO_PI.md diff --git a/Clean_Time_domain_BP.py b/Clean_Time_domain_BP.py deleted file mode 100644 index 31bf9f0..0000000 --- a/Clean_Time_domain_BP.py +++ /dev/null @@ -1,731 +0,0 @@ -""" -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 - -# ══════════════════════════════════════════════════════ -# 0.1 ИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ -# ══════════════════════════════════════════════════════ -INPUT_IDX = [0, 1, 2, 3] -OUTPUT_IDX = [2, 3] - -# Частотный диапазон и глубинный gate оставлены в прошлой логике. -F_START = 27 * 1e8 -F_STOP = 6*1e9 -MIN_DEPTH = 2.7 -MAX_DEPTH = 15.0 - -# Данные и Вычитание среднего фона - как обычно -BG_SUBTRACT = True -BG_PATH = Path('/Users/ivan_root/Desktop/GPR data/20260422_for_Vanya/20260422_night/20260422_evening_scene_balvanka_z/preprocessed') - -DATA_PATH = Path('/Users/ivan_root/Desktop/GPR data/20260422_for_Vanya/20260422_night/20260422_evening_scene_balvanka_z/preprocessed/0002_id1_ns5952943855654') - -# Убрать паразитные боковые лепестки с 2D карты -BP_REMOVE_SIDELOBE_OBJECTS = True # True: убрать SL-кандидаты из финальной таблицы и разметки - -# Компенсация затухания в этой версии разделена на 2 части: -# range: геометрическое расхождение 1/(Rtx*Rrx) -# angle: диаграмма направленности cos_tx^2 * cos_rx^2 -COMP_RANGE_POWER = 0.28 # Можно менять свободно, текущее значение подобрано экспериментально -COMP_ANGLE_POWER = 0.10 # Можно менять но лучше ставить не больше 0.25. Текущее значение подобрано нормально - -# ══════════════════════════════════════════════════════ -# 0.2 КОНФИГИ АНТЕНН -# ══════════════════════════════════════════════════════ - -# Физические координаты антенн по их реальным индексам, [м] - текущий конфиг -TX_POSITIONS = { - 2: -74.5 * 0.01, - 3: 75.0 * 0.01, -} - -RX_POSITIONS = { - 0: 19.0 * 0.01, - 1: 44.5 * 0.01, - 2: -40.0 * 0.01, - 3: -19.0 * 0.01, -} -# Вариант для теста на старых конфигурациях -# TX_POSITIONS = {0: 90.5*0.01, # Tx с индексом 0 → x = +0.9 м -# 3: -90.5*0.01} # Tx с индексом 3 → x = -0.9 м - -# RX_POSITIONS = {0: -18*0.01, -# 1: 48.5*0.01, -# 2: -49.0*0.01, -# 3: 18.5*0.01} - -# ══════════════════════════════════════════════════════ -# 0.3 НЕИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ (ЛУЧШЕ НЕ ТРОГАТЬ) -# ══════════════════════════════════════════════════════ - -eps_r = 1.0 -v = 3e8 / np.sqrt(eps_r) - -x_tx = np.array([TX_POSITIONS[i] for i in sorted(TX_POSITIONS)]) -x_rx = np.array([RX_POSITIONS[j] for j in sorted(RX_POSITIONS)]) - - -# Сетка BP-карты -x_min, x_max = x_tx.min() - 2.0, x_tx.max() + 2.0 -z_min, z_max = 0.1, MAX_DEPTH -NX_BP = 300 -NZ_BP = 300 - -# Oversampling A-сканов: искусственно повышает плотность точек по t, но не физическое разрешение. -BP_OVERSAMPLE = 8 - -# Компенсация ослабления разделена на две части: -# range: геометрическое расхождение 1/(Rtx*Rrx) -# angle: диаграмма направленности cos_tx^2 * cos_rx^2 -# Компенсация нормируется на точку под виртуальным центром пары на глубине COMP_REF_DEPTH. -COMP_RANGE_WEIGHT_MAX = 5.0 -COMP_ANGLE_WEIGHT_MAX = 2.0 -COMP_WEIGHT_MAX = 8.0 # общий финальный потолок после перемножения range*angle -COMP_REF_DEPTH = 3.0 - -# Сглаживание только для удобства поиска/визуализации максимума. -BP_SMOOTH_SIGMA = 1.5 - -# Параметры поиска объектов на BP-карте. -MAX_OBJECTS = 10 -BP_OBJECT_MIN_FRAC = 0.35 # остановка: пик ниже этой доли от глобального максимума -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 # кандидат должен быть слабее родительского максимума - - - - -# ══════════════════════════════════════════════════════ -# 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.' - -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 = {} - -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, - 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) - -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} см') - - -# ══════════════════════════════════════════════════════ -# 3. TIME-DOMAIN INCOHERENT 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 attenuation_components_map(i_tx, i_rx, XX, ZZ): - Rtx = np.sqrt((XX - x_tx[i_tx])**2 + ZZ**2) - Rrx = np.sqrt((XX - x_rx[i_rx])**2 + ZZ**2) - geo = 1.0 / (Rtx * Rrx + 1e-12) - angle = (ZZ / (Rtx + 1e-12))**2 * (ZZ / (Rrx + 1e-12))**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 = np.sqrt((xc - x_tx[i_tx])**2 + z_ref**2) - Rrx = np.sqrt((xc - x_rx[i_rx])**2 + z_ref**2) - geo = 1.0 / (Rtx * Rrx + 1e-12) - angle = (z_ref / (Rtx + 1e-12))**2 * (z_ref / (Rrx + 1e-12))**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 = np.sqrt((XX_bp - x_tx[i])**2 + ZZ_bp**2) - Rrx = np.sqrt((XX_bp - x_rx[j])**2 + ZZ_bp**2) - 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 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, - '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 bistatic_depth_signature(x_obj, z_obj): - signature = [] - for i in range(N_tx): - for j in range(N_rx): - Rtx = np.sqrt((x_obj - x_tx[i])**2 + z_obj**2) - Rrx = np.sqrt((x_obj - x_rx[j])**2 + z_obj**2) - 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 - - -#print('Расчет coherent time-domain BP...', end=' ', flush=True) -bp_raw, bp_complex = backproject_coherent(H_bp, T_bp, compensate=True) -bp_map = bp_raw / (bp_raw.max() + 1e-12) -bp_map_s = gaussian_filter(bp_map, sigma=BP_SMOOTH_SIGMA) -bp_map_s = np.where(depth_gate, bp_map_s, 0.0) -bp_map_s = bp_map_s / (bp_map_s.max() + 1e-12) -#print('готово.') - - -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 = mark_sidelobe_candidates(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 = [obj for obj in bp_objects_all if not obj['sidelobe_candidate']] -else: - bp_objects = bp_objects_all - -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" centroid: x = {main_obj['x']*100:+.1f} см, z = {main_obj['z']*100:.1f} см, " -# f"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('=' * 126) -# print(f" {'#':<4} {'Xc [см]':>10} {'Zc [см]':>10} {'Xmax [см]':>11} {'Zmax [см]':>11} " -# f"{'Peak':>8} {'Area [см2]':>11} {'PhCoh':>7} {'PhVar':>7} {'SL?':>5} {'Parent':>6} {'RMSr [см]':>10}") -# print('-' * 126) -# for obj in bp_objects: -# 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['index']:<4} {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']:>8.3f} {obj['area_cm2']:>11.1f} " -# #f"{obj['phase_coherence']:>7.3f} {obj['phase_circular_variance']:>7.3f} " -# f"{sl_label:>5} {parent_label:>6} {rms_label:>10}") -# print('=' * 126) - - -# ══════════════════════════════════════════════════════ -# 4. ГРАФИКИ ДЛЯ СРАВНЕНИЯ -# ══════════════════════════════════════════════════════ - -# BP-карта -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, # Было 0.0 - vmax=0.95, # Было 1.0 -) -plt.colorbar(im, ax=ax, label='Нормированная |coherent BP|') -ax.plot(x_tx * 100, np.zeros(N_tx), 'r^', ms=12, label='Tx', zorder=5) -ax.plot(x_rx * 100, np.zeros(N_rx), 'bv', ms=12, label='Rx', zorder=5) -for obj in bp_objects: - 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['index']) - text_color = 'white' - ax.text(obj['x'] * 100 + 3, obj['z'] * 100, label_text, - color=text_color, fontsize=9, weight='bold', zorder=7) - -ax.plot(x_peak * 100, z_peak * 100, 'w*', ms=18, markeredgecolor='k', mew=0.9, - label='Глобальный максимум BP', zorder=6) -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 = 'Time-domain coherent BackProjection с geo·pattern компенсацией' -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() \ No newline at end of file diff --git a/Ellips_motion_remake_2.py b/Ellips_motion_remake_2.py deleted file mode 100644 index 22f39a6..0000000 --- a/Ellips_motion_remake_2.py +++ /dev/null @@ -1,956 +0,0 @@ -""" -MIMO GPR — локализация через пересечение эллипсов -================================================== - -Физика в двух словах: - Пик A-скана пары (Tx_i, Rx_j) на задержке τ означает: - |Tx → объект| + |объект → Rx| = v · τ - Это уравнение эллипса. Истинный отражатель лежит на - пересечении всех Tx/Rx-эллипсов (по одному на измеренную пару). - -Алгоритм: - 1. S(f) → IFFT → A-сканы всех Tx/Rx-пар - 2. Поиск пиков: SNR = пик / медиана > порог - 3. Для каждого пика → мягкий эллипс в аккумуляторе - (с компенсацией геометрического и углового затухания) - 4. CLEAN: найти максимум → убрать его эллипсы → повторить - -О параметре SHELL_SIGMA: - Аккумулятор — это «мягкое голосование». Каждый эллипс добавляет - не единицу, а гауссово-взвешенный вклад: - w = exp(−δ²/2σ²), где δ = |R_Tx + R_Rx − v·τ| - SHELL_SIGMA — ширина этой гауссовой оболочки. - Слишком широко → ghost-цели не подавляются. - Слишком узко → вклад падает до нуля из-за дискретности сетки. - Оптимум: ~ 0.4 × δZ, где δZ = v/(2B) — разрешение по глубине. - -О score: - score = количество пар, чей эллипс проходит - через данную точку с невязкой δ < 3σ. - Принимает целые значения от 0 до N_pairs. - Максимальный score у истинного объекта = N_pairs (все пары согласны). - Ghost-цели имеют меньший score, т.к. согласуются только - с частью пар. - -О компенсации затухания: - При генерации S(f) сигнал ослаблен: - geo(i,j) = 1/(R_Tx · R_Rx) — геометрическое ослабление - pat(i,j) = cos²(θ_Tx)·cos²(θ_Rx) — диаграмма направленности - Без компенсации глубокий/угловой отражатель будет недооценён. - Компенсация: делим вес каждого пика на ожидаемое затухание - в точке z_apparent, вычисленное для данной пары антенн. - -Геометрия: карта строится в плоскости XZ при y=ELLIPS_PLANE_Y, -но бистатические дальности считаются до 3D-координат антенн. -""" - -""" -MIMO GPR — локализация через пересечение эллипсов -================================================== -Версия для реальных данных -""" - -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from scipy.signal import find_peaks -from scipy.ndimage import gaussian_filter, label -from pathlib import Path -from dataclasses import dataclass, field -from typing import Dict, List, Tuple - -# ══════════════════════════════════════════════════════ -# 0.1 ИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ -# ══════════════════════════════════════════════════════ -INPUT_IDX = [0, 1, 2, 3] -OUTPUT_IDX = [0, 1] - -# Частотный диапазон и глубинный gate. -F_START = 40 * 1e8 -F_STOP = 60 * 1e8 -MIN_DEPTH = 3.0 -MAX_DEPTH = 14.5 - -# Движение радара между Tx-событиями. -# Новая схема измерения: один Tx излучает, все 4 Rx принимают одновременно; -# затем излучает следующий Tx. Поэтому motion correction имеет 2 временные точки, -# а не 8 последовательных точек для отдельных Tx/Rx-пар. -SPEED_M_S = 1.11 -LOOK_ANGLE_DEG = 12.0 # угол между направлением движения и осью дальности Z -TX_SWEEP_TIME_S = 0.072 # время одного sweep для одного Tx-события -TX_SWITCH_TIME_S = 1e-5 -APPLY_FREQ_PHASE_CORRECTION = True # True: корректировать движение внутри sweep до IFFT - -# Данные и вычитание среднего фона. -# True: вычитать среднее по всем снимкам в папке, убирая прямую волну и статику. -# False: использовать данные как есть. -BG_SUBTRACT = True -BG_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/moving_26052026/20260526_10-6000_751_50k_triplet_fast_1/preprocessed') -DATA_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/moving_26052026/20260526_10-6000_751_50k_triplet_fast_1/preprocessed/0046_id47_ns3315537839930') - -# Параметры поиска пиков и CLEAN по карте эллипсов. -MODE = 'point' # 'point' или 'extended' -SNR_THRESH = 3.8 # минимальный SNR пика A-скана -SNR_COMP_MAX = 25.0 # верхний предел компенсированного SNR -COMP_POWER = 0.22 # степень компенсации геометрического/углового затухания -MAX_OBJECTS = 15 - -# ══════════════════════════════════════════════════════ -# 0.2 КОНФИГИ АНТЕНН И ДВИЖЕНИЯ -# ══════════════════════════════════════════════════════ - -# Физические координаты антенн по их реальным индексам, [м]. -# Формат: x, (x, y) или (x, y, z). X - поперечная ось, Z - наклонная -# дальность вдоль оси радара, Y - привязанная к радару вертикальная ось. -# Карта строится в плоскости y=ELLIPS_PLANE_Y, но расстояния Tx/Rx считаются -# в полном 3D. Это даёт 2D проекцию бистатических эллипсов без 3D volume search. -ELLIPS_PLANE_Y = 0.0 - -TX_POSITIONS = { - 0: (-75.0 * 0.01, 0.0, 0.0), - 1: ( 75.0 * 0.01, 0.0, 0.0), -} - -RX_POSITIONS = { - 0: ( 19.0 * 0.01, 0.0, 0.0), - 1: ( 45.0 * 0.01, 0.0, 0.0), - 2: (-45.0 * 0.01, 0.0, 0.0), - 3: (-19.0 * 0.01, 0.0, 0.0), -} - - -@dataclass -class MotionConfig: - """ - Конфигурация движения для кадра с параллельным приёмом по Rx. - - speed_m_s: - Линейная скорость радара во время съёмки кадра. - - look_angle_deg: - Угол между направлением движения и осью дальности Z. - Если движение почти вдоль дальности, ставьте угол близкий к 0°. - - tx_sweep_time_s: - Время sweep для одного Tx-события. Все Rx этого Tx имеют один timestamp. - - tx_switch_time_s: - Время переключения между соседними Tx-событиями. - - pair_order_phys: - Порядок пар в физических индексах, как они записаны в данных: - [(tx_phys_1, rx_phys_1), (tx_phys_2, rx_phys_2), ...] - При расчёте движения пары с одинаковым tx_phys считаются одновременными. - - reference_mode: - Относительно какого момента считаем dt: - - 'frame_center' : середина между первым и последним Tx-событием; - - 'first_tx_event' : центр первого Tx-события. - - direction_sign: - Знак движения по оси дальности. - +1 -> более поздние Tx-события выглядят глубже; - -1 -> более поздние Tx-события выглядят ближе. - - apply_freq_phase_correction: - True -> до IFFT компенсировать движение внутри одного Tx-sweep, - потому что частоты измеряются последовательно снизу вверх. - """ - speed_m_s: float = 0.0 - look_angle_deg: float = 0.0 - tx_sweep_time_s: float = 0.15 - tx_switch_time_s: float = 1e-5 - pair_order_phys: List[Tuple[int, int]] = field(default_factory=lambda: [ - (tx_phys, rx_phys) - for tx_phys in sorted(OUTPUT_IDX) - for rx_phys in sorted(INPUT_IDX) - ]) - reference_mode: str = 'frame_center' - direction_sign: float = +1.0 - apply_freq_phase_correction: bool = True - - -MOTION_CONFIG = MotionConfig( - speed_m_s=SPEED_M_S, - look_angle_deg=LOOK_ANGLE_DEG, - tx_sweep_time_s=TX_SWEEP_TIME_S, - tx_switch_time_s=TX_SWITCH_TIME_S, - pair_order_phys=[ - (0, 0), (0, 1), (0, 2), (0, 3), - (1, 0), (1, 1), (1, 2), (1, 3), - ], - reference_mode='frame_center', - direction_sign=+1.0, - apply_freq_phase_correction=APPLY_FREQ_PHASE_CORRECTION, -) - -# ══════════════════════════════════════════════════════ -# 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 - -# Границы сетки аккумулятора в плоскости y=ELLIPS_PLANE_Y. -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.2, MAX_DEPTH - - -# ══════════════════════════════════════════════════════ -# 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 - - # Переводим физический индекс → порядковый (0,1,2,...) - 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) - - tx_indices = sorted(set(k[0] for k in s21_data)) - rx_indices = sorted(set(k[1] for k in s21_data)) - n_tx = len(tx_indices) - n_rx = len(rx_indices) - - print(f"Загружено пар: {len(s21_data)}") - print(f"Передатчиков: {n_tx}, Приёмников: {n_rx}") - - return s21_data, freq_data, n_tx, n_rx - - -# Загрузка данных -s21_data, freq_data, N_tx, N_rx = load_mimo_data(DATA_PATH, INPUT_IDX, OUTPUT_IDX) -N_pairs = len(s21_data) - -# ══════════════════════════════════════════════════════ -# 1б. ВЫЧИСЛЕНИЕ СРЕДНЕГО ФОНА ПО ВСЕМ СНИМКАМ -# ══════════════════════════════════════════════════════ - -def compute_background(bg_path, input_idx, output_idx): - """ - Для каждой пары (i_tx, i_rx) усредняем S21 по всем снимкам в папке. - - Возвращает: - bg : dict[(i_tx, i_rx)] → np.array (complex), усреднённый S21 - """ - bg_path = Path(bg_path) - snapshots = sorted(bg_path.glob("*/")) # каждый подкаталог — один снимок - snapshots = [s for s in snapshots if s.is_dir()] - - if len(snapshots) == 0: - print("⚠️ Снимков для фона не найдено, BG_SUBTRACT отключён.") - return None - - print(f"Вычисление фона по {len(snapshots)} снимкам...", end=" ", flush=True) - - # Накопитель: для каждой пары суммируем S21 - 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=complex) - 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)}, снимков на пару: " - f"{list(bg_count.values())[0] if bg_count else 0}") - return bg - - -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] - - - -# Проверим что частоты одинаковые для всех пар -for key, freq in freq_data.items(): - if not np.allclose(freq, freqs): - print(f"⚠️ Частоты для пары {key} отличаются!") - - -mask_freq = (freqs >= F_START) & (freqs <= F_STOP) - -freqs = freqs[mask_freq] - -f_min, f_max = freqs[0], freqs[-1] -BW = f_max - f_min -N_f = len(freqs) - - -# ══════════════════════════════════════════════════════ -# 2. ПАРАМЕТРЫ СИСТЕМЫ -# ══════════════════════════════════════════════════════ - -# Проверка соответствия координатов антенн -assert len(x_tx) == N_tx, f"x_tx должен содержать {N_tx} элементов" -assert len(x_rx) == N_rx, f"x_rx должен содержать {N_rx} элементов" - - -# SHELL_SIGMA — ширина гауссовой оболочки -SHELL_SIGMA = v / BW * 0.5 # [м] - - -# Сетка аккумулятора - -x_grid = np.linspace(x_min, x_max, 300) -z_grid = np.linspace(z_min, z_max, 300) -XX, ZZ = np.meshgrid(x_grid, z_grid) - -def range_to_tx(i_tx, X, Z, yy=ELLIPS_PLANE_Y): - return np.sqrt((X - x_tx[i_tx])**2 + (yy - y_tx[i_tx])**2 + (Z - z_tx[i_tx])**2) - - -def range_to_rx(i_rx, X, Z, yy=ELLIPS_PLANE_Y): - return np.sqrt((X - x_rx[i_rx])**2 + (yy - y_rx[i_rx])**2 + (Z - z_rx[i_rx])**2) - - -def bistatic_ranges(i_tx, i_rx, X, Z, yy=ELLIPS_PLANE_Y): - Rtx = range_to_tx(i_tx, X, Z, yy=yy) - Rrx = range_to_rx(i_rx, X, Z, yy=yy) - return Rtx, Rrx - - -def antenna_boresight_cos_z(R, z_ant, Z): - # В локальных координатах радара все антенны смотрят вдоль +Z. - return (Z - z_ant) / (R + 1e-12) - - -# Расстояния от 2D-сетки до 3D-координат антенн. -# Это не 3D поиск: мы строим карту только в плоскости y=ELLIPS_PLANE_Y, -# но каждая точка карты получает корректную бистатическую дальность в 3D. -R_tx_grid = {i: range_to_tx(i, XX, ZZ) for i in range(N_tx)} -R_rx_grid = {j: range_to_rx(j, XX, ZZ) for j in range(N_rx)} - - -# ══════════════════════════════════════════════════════ -# 3. ВЫЧИСЛЕНИЕ A-СКАНОВ ИЗ РЕАЛЬНЫХ S21 -# ══════════════════════════════════════════════════════ - -def compute_ascan(s21, freq, f_start, f_stop, window=True): - """ - S21(f) → IFFT → A-скан с правильным частотным сдвигом. - - Проблема наивного подхода (buf[:n] = s21): - IFFT считает, что спектр начинается с 0 Гц. - Реальные данные начинаются с f[0] > 0, поэтому - нулевая задержка смещается и в A-скане появляются биения. - - Правильный подход — сдвиг спектра: - Шаг частотной сетки df вычисляется из данных. - Индекс первой частоты: k0 = round(f[0] / df). - Данные кладутся в H[k0 : k0+n], а не в H[0 : n]. - Тогда IFFT корректно восстанавливает временной сигнал - с нулевой задержкой в t=0. - - Размер FFT: - Минимум для покрытия всего диапазона [0, f[-1]]: - min_len = 2 * (k0 + n - 1) - Округляем вверх до степени двойки для скорости FFT. - """ - mask_freq_ = (freq >= f_start) & (freq <= f_stop) - freq = freq[mask_freq_] - s21 = s21[mask_freq_] - - n = len(freq) - if n < 2: - raise ValueError("Слишком мало частотных точек") - - # Шаг частотной сетки - df = (freq[-1] - freq[0]) / (n - 1) - if df <= 0: - raise ValueError("Частоты не возрастают") - - # Индекс первой частоты в полной сетке от 0 до f[-1] - k0 = int(np.round(freq[0] / df)) - - # Минимальный размер FFT, округлённый до степени двойки - min_len = 2 * (k0 + n - 1) - n_fft = 1 << int(np.ceil(np.log2(min_len))) - - - # Временна́я ось — пересчитываем из нового n_fft - dt = 1.0 / (n_fft * df) - t_sec = np.arange(n_fft, dtype=float) * dt - - # Оконная функция (подавление боковых лепестков IFFT) - s = s21 * np.hanning(n) if window else s21.copy() - - # Спектр со сдвигом: данные на своём месте в частотной сетке - H = np.zeros(n_fft, dtype=np.complex128) - H[k0 : k0 + n] = s - - y = np.abs(np.fft.ifft(H)) - - - return t_sec[:y.size], y[:y.size] - - -def _build_phys_to_logical_maps(output_idx, input_idx): - tx_map = {phys: k for k, phys in enumerate(sorted(output_idx))} - rx_map = {phys: k for k, phys in enumerate(sorted(input_idx))} - return tx_map, rx_map - - -def _tx_event_order_from_pairs(pair_order_phys): - """Возвращает порядок Tx-событий по первому появлению tx_phys в pair_order_phys.""" - tx_events = [] - for tx_phys, _ in pair_order_phys: - if tx_phys not in tx_events: - tx_events.append(tx_phys) - return tx_events - - -def compute_pair_timestamps(config: MotionConfig, - output_idx=OUTPUT_IDX, - input_idx=INPUT_IDX) -> Tuple[Dict[Tuple[int, int], Dict], List[Dict]]: - """ - Для каждой пары возвращает t_start/t_center Tx-события и грубый pair shift. - - Пары с одинаковым tx_phys получают одинаковое время, потому что Rx принимают - параллельно. t_start/t_center также используются для частотной phase correction - внутри sweep до IFFT. - """ - tx_phys_to_log, rx_phys_to_log = _build_phys_to_logical_maps(output_idx, input_idx) - - expected_pairs = {(tx, rx) for tx in output_idx for rx in input_idx} - observed_pairs = set(config.pair_order_phys) - missing_pairs = expected_pairs - observed_pairs - extra_pairs = observed_pairs - expected_pairs - if missing_pairs: - raise ValueError(f"pair_order_phys не содержит пары: {sorted(missing_pairs)}") - if extra_pairs: - raise ValueError(f"pair_order_phys содержит лишние пары: {sorted(extra_pairs)}") - if len(config.pair_order_phys) != len(observed_pairs): - raise ValueError("pair_order_phys содержит повторяющиеся пары") - - tx_event_order = _tx_event_order_from_pairs(config.pair_order_phys) - tx_event_timing = {} - for event_idx, tx_phys in enumerate(tx_event_order): - if tx_phys not in tx_phys_to_log: - raise ValueError(f"Tx {tx_phys} отсутствует в OUTPUT_IDX={output_idx}") - - t_start = event_idx * (config.tx_sweep_time_s + config.tx_switch_time_s) - t_center = t_start + 0.5 * config.tx_sweep_time_s - t_stop = t_start + config.tx_sweep_time_s - tx_event_timing[tx_phys] = { - 'tx_event_idx': event_idx, - 't_start_s': t_start, - 't_center_s': t_center, - 't_stop_s': t_stop, - } - - if not tx_event_order: - return {}, [] - - first_center = tx_event_timing[tx_event_order[0]]['t_center_s'] - last_center = tx_event_timing[tx_event_order[-1]]['t_center_s'] - if config.reference_mode == 'frame_center': - t_ref = 0.5 * (first_center + last_center) - elif config.reference_mode == 'first_tx_event': - t_ref = first_center - else: - raise ValueError("reference_mode must be 'frame_center' or 'first_tx_event'") - - cos_theta = np.cos(np.radians(config.look_angle_deg)) - pair_timestamps: Dict[Tuple[int, int], Dict] = {} - rows: List[Dict] = [] - - for order_idx, (tx_phys, rx_phys) in enumerate(config.pair_order_phys): - if rx_phys not in rx_phys_to_log: - raise ValueError(f"Rx {rx_phys} отсутствует в INPUT_IDX={input_idx}") - - i_tx = tx_phys_to_log[tx_phys] - i_rx = rx_phys_to_log[rx_phys] - event = tx_event_timing[tx_phys] - dt_ref = event['t_center_s'] - t_ref - dz_motion = config.direction_sign * config.speed_m_s * dt_ref * cos_theta - dtau_motion = 2.0 * dz_motion / v - - row = { - 'order_idx': order_idx, - 'tx_event_idx': event['tx_event_idx'], - 'tx_phys': tx_phys, - 'rx_phys': rx_phys, - 'i_tx': i_tx, - 'i_rx': i_rx, - 't_start_s': event['t_start_s'], - 't_center_s': event['t_center_s'], - 't_stop_s': event['t_stop_s'], - 'dt_ref_s': dt_ref, - 'dz_motion_m': dz_motion, - 'dtau_motion_s': dtau_motion, - } - rows.append(row) - pair_timestamps[(i_tx, i_rx)] = row.copy() - - return pair_timestamps, rows - - -def compute_frequency_sample_times(freq_full: np.ndarray, - f_start: float, - f_stop: float, - pair_info: Dict, - sweep_time_s: float) -> Tuple[np.ndarray, np.ndarray]: - """Возвращает индексы выбранных частот и абсолютное время каждой точки sweep.""" - mask = (freq_full >= f_start) & (freq_full <= f_stop) - idx = np.flatnonzero(mask) - if idx.size == 0: - raise ValueError('После частотной обрезки не осталось точек для phase correction') - - if len(freq_full) < 2: - t_abs = np.full(idx.size, pair_info['t_center_s'], dtype=float) - else: - dt_freq = sweep_time_s / (len(freq_full) - 1) - t_abs = pair_info['t_start_s'] + idx * dt_freq - - return idx, t_abs - - -def apply_intra_sweep_phase_correction(s21: np.ndarray, - freq_full: np.ndarray, - pair_info: Dict, - config: MotionConfig, - wave_speed: float, - f_start: float, - f_stop: float): - """ - Частотная motion correction до IFFT. - - Частоты внутри одного Tx-sweep измеряются последовательно снизу вверх. - Каждая частотная точка имеет своё положение радара, поэтому приводим её - фазу к центру этого Tx-события: - phi(f) = 2*pi*f * 2*dz_intra / wave_speed - dz_intra = v_motion*cos(theta)*(t_freq - t_center_tx_event) - После этого A-скан строится уже из phase-corrected S21. - """ - s21_corr = np.array(s21, dtype=np.complex128, copy=True) - idx, t_abs = compute_frequency_sample_times( - freq_full=freq_full, - f_start=f_start, - f_stop=f_stop, - pair_info=pair_info, - sweep_time_s=config.tx_sweep_time_s, - ) - - dt_intra = t_abs - pair_info['t_center_s'] - theta = np.radians(config.look_angle_deg) - delta_range = config.direction_sign * config.speed_m_s * np.cos(theta) * dt_intra - delta_path = 2.0 * delta_range - phi = 2.0 * np.pi * freq_full[idx] * delta_path / wave_speed - - if config.apply_freq_phase_correction: - s21_corr[idx] *= np.exp(1j * phi) - - meta = { - 'enabled': bool(config.apply_freq_phase_correction), - 'freq_idx': idx, - 't_abs_s': t_abs, - 'dt_intra_s': dt_intra, - 'delta_range_m': delta_range, - 'delta_path_m': delta_path, - 'phi_rad': phi, - } - return s21_corr, meta - - -pair_timestamps, pair_timing_rows = compute_pair_timestamps(MOTION_CONFIG) - -print("Вычисление A-сканов из реальных данных...", end=" ", flush=True) - -A_RAW = {} # A_RAW[(i,j)] — A-скан без внутрисвиповой phase correction -A = {} # A[(i,j)] — phase-corrected A-скан, используемый дальше -T_h = {} # T_h[(i,j)] — временна́я ось для этой пары [с] -Z_h = {} # Z_h[(i,j)] — ось глубины [м] -phase_meta = {} - -for (i, j), s21 in s21_data.items(): - s21_proc = np.array(s21, dtype=np.complex128, copy=True) - - # Вычитание фона в частотной области - if BG_SUBTRACT and background is not None and (i, j) in background: - s21_proc = s21_proc - background[(i, j)] - # Примечание: вычитаем до обрезки по частоте и до окна — - # фон вычисляется из полных (необрезанных) данных, - # поэтому вычитание корректно в полном частотном диапазоне. - - if (i, j) not in pair_timestamps: - raise KeyError(f'Нет временной информации для пары {(i, j)}') - - t_pair_raw, a_pair_raw = compute_ascan(s21_proc, freq_data[(i, j)], - f_start=F_START, f_stop=F_STOP) - s21_corr, meta = apply_intra_sweep_phase_correction( - s21=s21_proc, - freq_full=freq_data[(i, j)], - pair_info=pair_timestamps[(i, j)], - config=MOTION_CONFIG, - wave_speed=v, - f_start=F_START, - f_stop=F_STOP, - ) - t_pair, a_pair = compute_ascan(s21_corr, freq_data[(i, j)], - f_start=F_START, f_stop=F_STOP) - - T_h[(i, j)] = t_pair - Z_h[(i, j)] = t_pair * v / 2 - A_RAW[(i, j)] = a_pair_raw - A[(i, j)] = a_pair - phase_meta[(i, j)] = meta - -bg_label = "с вычитанием фона" if BG_SUBTRACT else "без вычитания фона" -phase_label = "с внутрисвиповой phase correction" if MOTION_CONFIG.apply_freq_phase_correction else "без внутрисвиповой phase correction" -print(f"готово ({bg_label}, {phase_label}).") - - -# Общая ось z для визуализации и поиска пиков -# (берём максимальный диапазон по всем парам) -z_h = Z_h[list(Z_h.keys())[0]] # все пары дают одинаковую ось, если freq совпадают -t_h = T_h[list(T_h.keys())[0]] - - -# ══════════════════════════════════════════════════════ -# 4. ДЕТЕКТИРОВАНИЕ ПИКОВ -# ══════════════════════════════════════════════════════ - -def attenuation_at_depth(i_tx, i_rx, z_app): - """ - Ожидаемое ослабление geo·pattern для точки прямо под виртуальным - центром пары на глубине z_app. - - Используется для компенсации: реальный SNR пика делится на это - значение, чтобы вес глубокого/углового объекта не занижался. - """ - xc = (x_tx[i_tx] + x_rx[i_rx]) / 2.0 # виртуальный центр в плоскости карты - Rtx, Rrx = bistatic_ranges(i_tx, i_rx, xc, z_app) - geo = 1.0 / (Rtx * Rrx + 1e-12) - cos_tx = antenna_boresight_cos_z(Rtx, z_tx[i_tx], z_app) - cos_rx = antenna_boresight_cos_z(Rrx, z_rx[i_rx], z_app) - pat = cos_tx**2 * cos_rx**2 - return geo * pat + 1e-30 # +ε чтобы не делить на ноль - - -def find_peaks_snr(i_tx, i_rx, SNR_COMP_MAX = SNR_COMP_MAX): - """ - Поиск пиков A-скана. - Возвращает список dict: - z_app — кажущаяся глубина [м] - tau — задержка [с] - snr_raw — SNR без компенсации = пик / медиана - snr_comp— SNR с компенсацией ослабления (используется в аккумуляторе) - """ - ascan = A[(i_tx, i_rx)] - z_h_ij = Z_h[(i_tx, i_rx)] - t_h_ij = T_h[(i_tx, i_rx)] - i_min = np.searchsorted(z_h_ij, MIN_DEPTH) - i_max = np.searchsorted(z_h_ij, MAX_DEPTH) - - noise = np.median(ascan[i_min:i_max]) # Добавил чтобы было удобно считать SNR отнсительно выбранной области - min_dist = max(4, int(v / (2*BW) / (z_h_ij[1] - z_h_ij[0]) * 0.7)) - idx, _ = find_peaks(ascan[i_min:i_max], - height=noise * SNR_THRESH, - distance=min_dist) - idx += i_min - result = [] - for p in idx: - z_app = float(z_h_ij[p]) - snr_raw = float(ascan[p] / noise) - atten = attenuation_at_depth(i_tx, i_rx, z_app) - atten_norm = atten / attenuation_at_depth(i_tx, i_rx, 3.0) # Референсная глубина - 3м - snr_comp = snr_raw / (atten_norm ** COMP_POWER + 1e-12) - snr_comp = min(snr_comp, SNR_COMP_MAX) # ← clipping - result.append({'z_app': z_app, - 'tau': float(t_h_ij[p]), - 'snr_raw': snr_raw, - 'snr_comp': snr_comp}) - return result - - -peaks = {(i, j): find_peaks_snr(i, j) - for i in range(N_tx) for j in range(N_rx)} - -# ══════════════════════════════════════════════════════ -# 6. ПОИСК ОБЪЕКТОВ -# ══════════════════════════════════════════════════════ - -def find_centroid(acc_s, iz, ix, rpz, rpx): - """ - Взвешенный центроид аккумулятора в окрестности (iz, ix). - """ - NZ, NX = acc_s.shape - iz0 = max(0, iz - rpz); iz1 = min(NZ, iz + rpz) - ix0 = max(0, ix - rpx); ix1 = min(NX, ix + rpx) - patch = acc_s[iz0:iz1, ix0:ix1].copy() - W = patch.sum() - if W <= 0: - return x_grid[ix], z_grid[iz] - rows = np.arange(iz0, iz1)[:, None] * np.ones(patch.shape) - cols = np.ones(patch.shape) * np.arange(ix0, ix1)[None, :] - iz_c = int(round(np.clip((rows * patch).sum() / W, 0, NZ-1))) - ix_c = int(round(np.clip((cols * patch).sum() / W, 0, NX-1))) - return x_grid[ix_c], z_grid[iz_c] - - -# ══════════════════════════════════════════════════════ -# 8. MOTION-AWARE FIRST-ORDER CORRECTION -# ══════════════════════════════════════════════════════ - -""" -Первый блок для движения без изменения продакшн-пайплайна выше. - -Новая схема измерения: -1. Tx2 излучает, Rx0..Rx3 принимают одновременно. -2. После переключения Tx3 излучает, Rx0..Rx3 снова принимают одновременно. -3. Поэтому timestamp задаётся не для 8 отдельных пар, а для 2 Tx-событий. -4. Для уже найденных пиков формируем motion-corrected версию: - tau_corr, z_corr - -Это first-order модель: считаем, что весь sweep одного Tx-события имеет один -центр времени. Если смещение внутри sweep станет заметным, следующим шагом -нужна per-frequency коррекция до IFFT. -""" - - -# compute_pair_timestamps(...) уже определён выше, потому что phase correction нужна до IFFT. - - -def build_corrected_peaks(peaks_in: Dict[Tuple[int, int], List[Dict]], - pair_timestamps: Dict[Tuple[int, int], Dict]) -> Dict[Tuple[int, int], List[Dict]]: - """ - Формирует словарь corrected_peaks с motion-aware поправками. - - Для каждого пика добавляет: - tau_phase, z_phase — пик после внутрисвиповой phase correction; - tau_corr, z_corr — тот же пик после грубого сдвига Tx-события; - dz_motion, dtau_motion. - - Для совместимости также оставляем aliases tau_raw/z_app_raw, но здесь - raw означает 'до грубого pair shift', а не до phase correction. - """ - corrected = {} - - for key, peak_list in peaks_in.items(): - if key not in pair_timestamps: - raise KeyError(f"Нет временной информации для пары {key}") - - info = pair_timestamps[key] - dz_motion = info['dz_motion_m'] - dtau_motion = info['dtau_motion_s'] - - corrected_list = [] - for pk in peak_list: - tau_phase = float(pk['tau']) - z_phase = float(pk['z_app']) - tau_corr = tau_phase + dtau_motion - z_corr = 0.5 * v * tau_corr - - pk_corr = dict(pk) - pk_corr.update({ - 'tau_phase': tau_phase, - 'z_phase': z_phase, - 'tau_raw': tau_phase, # alias для старых диагностических блоков - 'z_app_raw': z_phase, # alias: пик до грубого pair shift - 'tau_corr': tau_corr, - 'z_corr': z_corr, - 'dz_motion': dz_motion, - 'dtau_motion': dtau_motion, - }) - corrected_list.append(pk_corr) - - corrected[key] = corrected_list - - return corrected - - - -pair_timestamps, pair_timing_rows = compute_pair_timestamps(MOTION_CONFIG) -corrected_peaks = build_corrected_peaks(peaks, pair_timestamps) - - - -# ══════════════════════════════════════════════════════ -# 9. MOTION-AWARE IMAGE BUILD FROM CORRECTED PEAKS -# ══════════════════════════════════════════════════════ - -""" -Эта ячейка строит motion-aware картинку, используя corrected_peaks из блока выше. - -Что меняется относительно статического продакшн-пайплайна: -- в аккумуляторе используется tau_corr вместо tau -- в apparent-depth логике CLEAN используется z_corr вместо z_app -- score считается по corrected пикам - -Исходные A-сканы остаются теми же, но на графике ниже можно показывать уже -motion-corrected положения пиков. -""" - - -def _peak_in_work_depth(pk): - return MIN_DEPTH <= pk['z_corr'] <= MAX_DEPTH - - - -def build_accumulator_motion(corrected_peaks_in, exclude_z_ranges): - acc = np.zeros_like(XX) - for i in range(N_tx): - for j in range(N_rx): - for pk in corrected_peaks_in[(i, j)]: - if not _peak_in_work_depth(pk): - continue - if any(lo <= pk['z_corr'] <= hi for lo, hi in exclude_z_ranges): - continue - r_total = v * pk['tau_corr'] - residual = R_tx_grid[i] + R_rx_grid[j] - r_total - shell = np.exp(-0.5 * (residual / SHELL_SIGMA)**2) - acc += shell * pk['snr_comp'] - return acc - - - -def count_agreeing_ellipses_motion(x_est, z_est, corrected_peaks_in, exclude_z_ranges): - count = 0 - for i in range(N_tx): - for j in range(N_rx): - for pk in corrected_peaks_in[(i, j)]: - if not _peak_in_work_depth(pk): - continue - if any(lo <= pk['z_corr'] <= hi for lo, hi in exclude_z_ranges): - continue - Rt, Rr = bistatic_ranges(i, j, x_est, z_est) - if abs(Rt + Rr - v * pk['tau_corr']) < SHELL_SIGMA * 6: - count += 1 - break - return count - - - -def clean_find_motion(corrected_peaks_in, n_search=10, suppress_r_cm=7, thresh_frac=0.05): - dx = x_grid[1] - x_grid[0] - dz = z_grid[1] - z_grid[0] - rpx = int(suppress_r_cm / 100 / dx) - rpz = int(suppress_r_cm / 100 / dz) - - excl_z = [] - found_motion = [] - acc_initial = build_accumulator_motion(corrected_peaks_in, []) - - for step in range(n_search): - acc = build_accumulator_motion(corrected_peaks_in, excl_z) - acc_s = gaussian_filter(acc, sigma=3) - - if acc_s.max() < thresh_frac * acc_initial.max(): - break - - iz, ix = np.unravel_index(acc_s.argmax(), acc_s.shape) - x_est, z_est = find_centroid(acc_s, iz, ix, rpz, rpx) - score = count_agreeing_ellipses_motion(x_est, z_est, corrected_peaks_in, excl_z) - found_motion.append({'x': x_est, 'z': z_est, 'score': score}) - - matched = [pk['z_corr'] - for i in range(N_tx) for j in range(N_rx) - for pk in corrected_peaks_in[(i, j)] - if _peak_in_work_depth(pk) - and not any(lo <= pk['z_corr'] <= hi for lo, hi in excl_z) - and abs(sum(bistatic_ranges(i, j, x_est, z_est)) - - v * pk['tau_corr']) < SHELL_SIGMA * 3] - - if matched: - margin = SHELL_SIGMA * 1.0 - excl_z.append((min(matched) - margin, max(matched) + margin)) - - return found_motion, acc_initial - - -if MODE == 'point': - found_motion, accum_motion = clean_find_motion(corrected_peaks, n_search=MAX_OBJECTS) - - -# ─── График 2: motion-aware карта накопления ─────────────────────── -fig, ax = plt.subplots(figsize=(12, 7)) -acc_motion_s = gaussian_filter(accum_motion, sigma=3) -im = ax.imshow( - acc_motion_s, - extent= [x_grid[0]*100, x_grid[-1]*100, z_grid[-1]*100, z_grid[0]*100], - aspect='auto', origin='upper', cmap='hot', - vmin=acc_motion_s.max()*0.45, vmax=acc_motion_s.max()*0.95, -) -plt.colorbar(im, ax=ax, label='Накопленный вес (motion-aware)') -ax.plot(x_tx*100, z_tx*100, 'r^', ms=10, label='Tx (XZ projection)', zorder=5) -ax.plot(x_rx*100, z_rx*100, 'bv', ms=10, label='Rx (XZ projection)', zorder=5) -for obj in found_motion: - lbl = f"score={obj['score']}/{N_pairs}" - ax.plot(obj['x']*100, obj['z']*100, 'wD', ms=9, zorder=11, markeredgecolor='black', mew=1.2) - ax.annotate(lbl, (obj['x']*100, obj['z']*100), textcoords='offset points', xytext=(6, 4), - fontsize=8, color='white', bbox=dict(boxstyle='round,pad=0.2', fc='black', alpha=0.5)) -ax.plot([], [], 'wD', ms=9, markeredgecolor='k', mew=1.2, label='Найденные объекты') -ax.set_xlabel('X [см]') -ax.set_ylabel('Глубина Z [см]') -ax.set_title('Motion-aware карта накопления эллипсов') -ax.set_xlim(x_grid[0]*100, x_grid[-1]*100) -ax.set_ylim(z_grid[-1]*100, z_grid[0]*100) -ax.legend(loc='lower right', fontsize=9) -ax.grid(alpha=0.25) -ax.invert_yaxis() -plt.tight_layout() -plt.show() \ No newline at end of file diff --git a/Horns_clean.py b/Horns_clean.py deleted file mode 100644 index 4b6c693..0000000 --- a/Horns_clean.py +++ /dev/null @@ -1,1086 +0,0 @@ -""" -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/a-librevna_minimal_driver_lifecycle.d b/a-librevna_minimal_driver_lifecycle.d deleted file mode 100644 index f802458..0000000 --- a/a-librevna_minimal_driver_lifecycle.d +++ /dev/null @@ -1,14 +0,0 @@ -librevna_minimal_driver_lifecycle.o: \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/../librevna_minimal_driver.hpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/interfaces/radar_driver.hpp \ - data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp \ - data_acq_and_processing/common_cpp/config/include/run_config.hpp \ - data_acq_and_processing/processing/locator/include/locator/locator_config.hpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_protocol_common.hpp -data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/../librevna_minimal_driver.hpp: -data_acq_and_processing/sweep_orchestrator/device_drivers/interfaces/radar_driver.hpp: -data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp: -data_acq_and_processing/common_cpp/config/include/run_config.hpp: -data_acq_and_processing/processing/locator/include/locator/locator_config.hpp: -data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_protocol_common.hpp: diff --git a/a-librevna_minimal_driver_protocol.d b/a-librevna_minimal_driver_protocol.d deleted file mode 100644 index 9984a08..0000000 --- a/a-librevna_minimal_driver_protocol.d +++ /dev/null @@ -1,14 +0,0 @@ -librevna_minimal_driver_protocol.o: \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_protocol.cpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/../librevna_minimal_driver.hpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/interfaces/radar_driver.hpp \ - data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp \ - data_acq_and_processing/common_cpp/config/include/run_config.hpp \ - data_acq_and_processing/processing/locator/include/locator/locator_config.hpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_protocol_common.hpp -data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/../librevna_minimal_driver.hpp: -data_acq_and_processing/sweep_orchestrator/device_drivers/interfaces/radar_driver.hpp: -data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp: -data_acq_and_processing/common_cpp/config/include/run_config.hpp: -data_acq_and_processing/processing/locator/include/locator/locator_config.hpp: -data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_protocol_common.hpp: diff --git a/a-librevna_minimal_driver_transport.d b/a-librevna_minimal_driver_transport.d deleted file mode 100644 index 4a32529..0000000 --- a/a-librevna_minimal_driver_transport.d +++ /dev/null @@ -1,14 +0,0 @@ -librevna_minimal_driver_transport.o: \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_transport.cpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/../librevna_minimal_driver.hpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/interfaces/radar_driver.hpp \ - data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp \ - data_acq_and_processing/common_cpp/config/include/run_config.hpp \ - data_acq_and_processing/processing/locator/include/locator/locator_config.hpp \ - data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_protocol_common.hpp -data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/../librevna_minimal_driver.hpp: -data_acq_and_processing/sweep_orchestrator/device_drivers/interfaces/radar_driver.hpp: -data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp: -data_acq_and_processing/common_cpp/config/include/run_config.hpp: -data_acq_and_processing/processing/locator/include/locator/locator_config.hpp: -data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_protocol_common.hpp: diff --git a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp index 8c36d78..44449dd 100644 --- a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp +++ b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp @@ -141,6 +141,7 @@ void DataPreprocessor::run(const std::atomic& stop_requested) { std::vector serialized_raw{}; ipc::RawSweepCollection raw_collection{}; std::uint64_t error_count = 0; + std::uint64_t consecutive_errors = 0; while (!stop_requested.load(std::memory_order_relaxed)) { try { if (!try_pop_raw_collection(serialized_raw, &raw_collection)) { @@ -148,6 +149,7 @@ void DataPreprocessor::run(const std::atomic& stop_requested) { } const auto preprocessed_collection = preprocess_collection(raw_collection); publish_preprocessed_collection(preprocessed_collection); + consecutive_errors = 0; // made progress; reset the escalation counter } catch (const std::exception& exc) { // A ring-slot-too-small failure is a permanent config error (the slot // cannot hold a serialized collection): swallowing it would silently @@ -157,12 +159,22 @@ void DataPreprocessor::run(const std::atomic& stop_requested) { } // A single malformed or transiently-bad collection must not kill the // long-running preprocessor: drop it and keep serving the next sweep. - // Logging is throttled so a persistent error cannot flood the log. - if (error_count % 100 == 0) { - std::cerr << "data_preprocessor: dropped collection after error (count=" - << (error_count + 1) << "): " << exc.what() << '\n'; - } ++error_count; + ++consecutive_errors; + // Escalate a PERSISTENT failure loudly so a deterministic config error + // (which drops every collection forever) is not hidden by the throttle. + if (consecutive_errors % 500 == 0) { + std::cerr << "data_preprocessor: " << consecutive_errors + << " consecutive failures — likely a permanent configuration error: " + << exc.what() << '\n'; + } else if (error_count % 100 == 1) { + std::cerr << "data_preprocessor: dropped collection after error (count=" + << error_count << "): " << exc.what() << '\n'; + } + // Back-off floored at 1ms (the empty-ring sleep is never reached while a + // persistent error keeps the ring non-empty) so the catch cannot busy-spin + // a CPU core even when idle_sleep_ms is configured to 0. + std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, config_.runtime.idle_sleep_ms))); } } } 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 74bd2fa..0d4714d 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 @@ -76,6 +76,12 @@ struct ProcessingLiveConfig { float legacy_gpr_min_visible_pair_count = 0.0F; std::uint32_t gpr_max_detected_objects_to_draw = 0; std::uint32_t gpr_draw_top_m_objects = 0; + // Visible X/Z window (metres). The locator clips broadcast objects to this + // window so the socket emits only what the desktop plot actually shows. + float gpr_visible_x_min_m = -2.0F; + float gpr_visible_x_max_m = 2.0F; + float gpr_visible_z_min_m = 0.0F; + float gpr_visible_z_max_m = 14.0F; // When true, the data_processor ignores socket-supplied `vlc` updates and // keeps using `gpr_speed_m_s` from this file. Mirrored from the GUI's // "ignore socket speed" checkbox. diff --git a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp index 0ad50be..23ae05f 100644 --- a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp +++ b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp @@ -64,6 +64,7 @@ void DataProcessor::run(const std::atomic& stop_requested) { std::uint64_t last_replayed_revision = live_config_loader_.revision(); std::uint64_t last_applied_history_command_seq = 0; std::uint64_t error_count = 0; + std::uint64_t consecutive_errors = 0; // Fix #55: track the socket-fed speed used for the last reprocess so a change // arriving without a live-config revision bump still triggers a reprocess of // the current result (gated below by reprocess_current_result). @@ -143,6 +144,7 @@ void DataProcessor::run(const std::atomic& stop_requested) { ); publish_result_collection(result_collection, results_ring_); publish_locator(result_collection, live_config); + consecutive_errors = 0; // made progress; reset the escalation counter continue; } @@ -155,14 +157,23 @@ void DataProcessor::run(const std::atomic& stop_requested) { throw; } // A single bad collection (torn ring slot, decode/processing error) must - // not kill the long-running processor: drop it and keep going. Throttle - // logging and pause briefly so a persistent error cannot busy-spin/flood. - if (error_count % 100 == 0) { - std::cerr << "data_processor: dropped collection after error (count=" - << (error_count + 1) << "): " << exc.what() << '\n'; - } + // not kill the long-running processor: drop it and keep going. ++error_count; - std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms)); + ++consecutive_errors; + // Escalate a PERSISTENT failure (a deterministic config error drops every + // collection forever): log it loudly and distinctly — not buried in the + // every-100 throttle — so liveness-only supervision still surfaces it. + if (consecutive_errors % 500 == 0) { + std::cerr << "data_processor: " << consecutive_errors + << " consecutive failures — likely a permanent configuration error: " + << exc.what() << '\n'; + } else if (error_count % 100 == 1) { + std::cerr << "data_processor: dropped collection after error (count=" + << error_count << "): " << exc.what() << '\n'; + } + // Back-off (floored at 1ms even if idle_sleep_ms==0) so a persistent error + // cannot busy-spin a core or flood the log. + std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, config_.runtime.idle_sleep_ms))); } } } @@ -221,6 +232,14 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; radar::locator::FilterParams filter{}; + // Clip broadcast objects to the same visible X/Z window the desktop plot uses, + // so the socket emits only the objects the operator actually sees. + filter.visible_bounds = radar::locator::VisibleBounds{ + .x_min = live_config.gpr_visible_x_min_m, + .x_max = live_config.gpr_visible_x_max_m, + .z_min = live_config.gpr_visible_z_min_m, + .z_max = live_config.gpr_visible_z_max_m, + }; if (requested_mode == "legacy_gpr") { filter.min_score = live_config.legacy_gpr_min_visible_pair_count; // The GUI deliberately disables the "draw top N" capping for legacy 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 a7b480b..f1b1a8a 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 @@ -348,6 +348,19 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_min_visible_score = static_cast(found->get()); } + for (const auto& [key, target] : { + std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m}, + std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m}, + std::pair{"gpr_visible_z_min_m", &config.gpr_visible_z_min_m}, + std::pair{"gpr_visible_z_max_m", &config.gpr_visible_z_max_m}, + }) { + if (const auto found = root.find(key); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error(std::string("processing.") + key + " must be number"); + } + *target = static_cast(found->get()); + } + } if (const auto found = root.find("legacy_gpr_min_visible_pair_count"); found != root.end()) { if (!found->is_number()) { throw std::runtime_error("processing.legacy_gpr_min_visible_pair_count must be number"); diff --git a/data_acq_and_processing/processing/locator/src/tcp_server.cpp b/data_acq_and_processing/processing/locator/src/tcp_server.cpp index 39682f6..e13628c 100644 --- a/data_acq_and_processing/processing/locator/src/tcp_server.cpp +++ b/data_acq_and_processing/processing/locator/src/tcp_server.cpp @@ -511,8 +511,16 @@ void TcpServer::acceptor_loop() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - // Listening socket closed during shutdown produces EBADF/EINVAL; bail. - break; + // Shutdown closes the listening socket (EBADF/EINVAL) -> bail. But an + // unexpected errno on a still-running server (e.g. EPERM from a firewall + // rule) must NOT kill the acceptor and silently stop serving new clients: + // log, back off, and keep accepting. + if (!running_.load(std::memory_order_acquire)) { + break; + } + std::cerr << "locator: accept() failed (errno=" << errno << "); retrying\n"; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; } reap_finished_clients(); diff --git a/S2VNA Operating and Programing Manual ver 26.1 2.pdf b/docs/S2VNA Operating and Programing Manual ver 26.1 2.pdf similarity index 100% rename from S2VNA Operating and Programing Manual ver 26.1 2.pdf rename to docs/S2VNA Operating and Programing Manual ver 26.1 2.pdf diff --git a/docs/k209_setup.md b/docs/k209_setup.md deleted file mode 100644 index b1d2023..0000000 --- a/docs/k209_setup.md +++ /dev/null @@ -1,308 +0,0 @@ -# Compact-M K209 / S2VNA Setup - -This project controls the Compact-M K209 through the S2VNA SCPI server. -The production path is: - -```text -K209 --USB-C--> S2VNA --HiSLIP/VISA--> radar_system -``` - -For complete run-mode instructions, including what runs on the x86_64 S2VNA -computer and what runs on Raspberry Pi, see -[`docs/operation_modes.md`](operation_modes.md). For `run_config.json` fields, -see [`docs/run_config.md`](run_config.md). - -There is no direct USB driver for K209 in this project. Do not use mock -transports, socket fallbacks, or `pyvisa-py` for the K209 path. The required -transport dependency is an IVI/Vendor VISA implementation that provides both -`visa.h` and `libvisa.so`. - -For maximum throughput the driver uses: - -- HiSLIP, not TCP Socket. -- A persistent VISA session. -- Binary `FORM:DATA REAL32`. -- Little-endian `FORM:BORD SWAP`. -- One-time sweep configuration outside the acquisition loop. -- Cached frequency axis after configuration, so repeated acquisition reads only - the complex traces. -- Point delay forced to `0` and sweep averaging forced `OFF`. -- The acquisition loop sends one synchronized SCPI message: - `TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21`. - This keeps the trigger state valid while avoiding separate round trips for - `*OPC?`, `S11`, and `S21`. - -Do not remove the inline `*OPC?` from the hot path. On the tested K209/S2VNA -setup, `TRIG:SING` followed immediately by data queries can return data but -queues SCPI error `-211,"Trigger system is not in the trigger wait state"`. - -## Required Components - -Install these on the machine that runs the K209 smoke tests or acquisition -process: - -1. S2VNA from Planar. - - On Linux the S2VNA manual describes the AppImage package - `S2VNA_X.X.X_x86_64.AppImage`. - - The K209 is connected to this S2VNA instance over USB-C. - -2. IVI VISA runtime and development files. - - Must provide `visa.h`. - - Must provide `libvisa.so`. - - Must support TCPIP HiSLIP resources. - - Suitable implementations include NI-VISA or Keysight IO Libraries Suite. - -3. Project Python environment. - - Use the repository virtual environment, not system Python. - - Install `requirements.txt` into `.venv`. - -4. Native build dependencies. - - C++20 compiler. - - `make`. - - `libusb-1.0` development files for the existing LibreVNA build. - -## Ubuntu x86_64 - -Install base packages: - -```bash -sudo apt update -sudo apt install build-essential make pkg-config python3-venv python3-pip libusb-1.0-0-dev -``` - -Create or update the project virtual environment: - -```bash -cd /home/europa/Documents/radar_system -python3 -m venv .venv -.venv/bin/python -m pip install --upgrade pip -.venv/bin/python -m pip install -r requirements.txt -``` - -Install an IVI VISA implementation. For NI-VISA, install the NI Linux package -repository from NI, then install the `ni-visa` package with the system package -manager. For Keysight, install Keysight IO Libraries Suite for Linux with VISA -support enabled. - -Do not use Ubuntu's `libvisa-dev` / `libvisa0` packages for the K209 production -path. Those packages come from `librevisa` and are not the vendor IVI VISA stack -used for high-speed HiSLIP operation. - -After installation, verify that the system exposes the required C and runtime -files: - -```bash -ldconfig -p | grep libvisa -find /usr /opt -name visa.h -o -name libvisa.so 2>/dev/null -.venv/bin/pyvisa-info -``` - -`pyvisa-info` must show the `ivi` backend with a found binary library. - -If `visa.h` or `libvisa.so` is installed outside the default compiler/linker -paths, pass the paths explicitly: - -```bash -make build/bin/k209_smoke_test \ - VISA_CXXFLAGS='-I/path/to/visa/include' \ - VISA_LDFLAGS='-pthread -lrt -L/path/to/visa/lib -lvisa' -``` - -## S2VNA HiSLIP Server - -Start S2VNA with the K209 connected over USB-C. Enable HiSLIP server on port -`4880`. This can be done from the S2VNA UI: - -```text -System -> Settings -> Remote control network settings -> HiSLIP server -> On -System -> Settings -> Remote control network settings -> HiSLIP port -> 4880 -``` - -The S2VNA command line can also enable the server: - -```bash -./S2VNA_X.X.X_x86_64.AppImage /HislipServer:on /HislipPort:4880 -``` - -For unattended runs, S2VNA also supports hiding the UI: - -```bash -./S2VNA_X.X.X_x86_64.AppImage /HislipServer:on /HislipPort:4880 /visible:off -``` - -Verify that the server is listening: - -```bash -ss -ltnp | grep 4880 -``` - -The local VISA resource is: - -```text -TCPIP0::127.0.0.1::hislip0,4880::INSTR -``` - -If S2VNA runs on another machine, replace `127.0.0.1` with that machine's IP -address. - -## Remote Raspberry Pi Mode - -For Raspberry Pi runs, keep S2VNA and NI-VISA on the x86_64 computer connected -to the K209, and run only the project pipeline/GPIO on the Raspberry Pi. - -On the x86_64 computer with S2VNA running: - -```bash -cd /path/to/radar_system -.venv/bin/python -m python_app.scripts.k209_remote_server --host 0.0.0.0 --port 50209 -``` - -On the Raspberry Pi, set the K209 config to the server address: - -```json -"radar": { - "model": "compact_m_k209", - "remote_host": "192.168.1.10", - "remote_port": 50209, - "driver_mode": "native" -} -``` - -The Raspberry Pi does not need S2VNA or NI-VISA for this mode. - -## Python Smoke Test - -Use the project virtual environment: - -```bash -cd /home/europa/Documents/radar_system -.venv/bin/python -m python_app.scripts.k209_smoke_test \ - --resource 'TCPIP0::127.0.0.1::hislip0,4880::INSTR' \ - --visa-library '@ivi' \ - --start-hz 10000000 \ - --stop-hz 100000000 \ - --points 11 \ - --ifbw-hz 10000 \ - --power-dbm -20 \ - --no-preset -``` - -Expected result: - -```text -K209 IDN: Planar, K209, ... -K209 sweep OK: points=11, first_hz=10000000.000, last_hz=100000000.000, ... -``` - -Use `--no-preset` for the first smoke test to avoid resetting the current S2VNA -session. Remove it when testing the full driver setup path. - -## C++ Smoke Test - -Build the C++ K209 smoke binary: - -```bash -cd /home/europa/Documents/radar_system -make build/bin/k209_smoke_test -``` - -Run it against the local S2VNA HiSLIP server: - -```bash -build/bin/k209_smoke_test \ - --resource 'TCPIP0::127.0.0.1::hislip0,4880::INSTR' \ - --start-hz 10000000 \ - --stop-hz 100000000 \ - --points 11 \ - --ifbw-hz 10000 \ - --power-dbm -20 \ - --no-preset -``` - -If the build fails with `fatal error: visa.h: No such file or directory`, the -IVI VISA development headers are not installed or are not visible to the -compiler. If linking fails with `cannot find -lvisa`, the IVI VISA runtime or -linker path is not installed correctly. - -## Python Sweep Benchmark - -Use this script to measure hot-loop sweep throughput for a selected sweep -configuration. It keeps one VISA session open, configures the sweep once, caches -the frequency axis once, then times repeated synchronized trigger/read cycles -for binary `S11` and `S21` arrays. - -Edit the configuration constants at the top of -`python_app/scripts/k209_sweep_benchmark.py`, then run: - -```bash -cd /home/europa/Documents/radar_system -.venv/bin/python -m python_app.scripts.k209_sweep_benchmark -``` - -The reported `points_per_s` is: - -```text -timed_sweeps * points / total_timed_seconds -``` - -Set `INCLUDE_RESULT_CONVERSION = True` only when you want to include Python -`SweepResult` construction overhead. Keep it `False` when measuring the -device/transport hot path. - -## K209 Limits - -The connected K209 reports these limits through SCPI service/capability -queries: - -```text -frequency_hz: 9000 .. 9000000000 -ifbw_hz: 1 .. 300000 -power_dbm: -55 .. +5 -points: 2 .. 500001 -``` - -The S2VNA manual specifies the IFBW range with 1/3-decade spacing. -`SENS:BAND` accepts values in the 1, 1.5, 2, 3, 5, 7 sequence across decades -and clamps out-of-range values to the nearest limit. - -The manual describes metrology/dynamic-range frequency subranges such as -`9 kHz..300 kHz`, `300 kHz..2 MHz`, and `2 MHz..9 GHz`, but it does not expose a -SCPI command for manually selecting an internal RF band. S2VNA handles internal -range switching. Benchmark the exact frequency window used by the application -when sweep speed matters. - -The S2VNA manual also describes a SCPI FIFO buffer mode for very high-rate -external-trigger sequences. It is not the right default for this driver stage: -the manual limits it to specific analyzer families, external/repeated trigger -workflows, one open channel, disabled display updates, and at most 3000 points -per sweep. The current K209 path therefore uses synchronized HiSLIP binary -sweeps instead of FIFO. - -## Raspberry Pi OS - -Raspberry Pi 5 uses ARM64/AArch64 when running 64-bit Raspberry Pi OS. The S2VNA -Linux package described in the S2VNA manual is `x86_64`, and common vendor VISA -packages are also primarily published for x86_64 Linux. - -Do not assume local K209 acquisition on Raspberry Pi works until both of these -are available for ARM64: - -1. S2VNA build that can run on Raspberry Pi OS ARM64 and control the K209 over - USB-C. -2. IVI VISA implementation for ARM64 that provides `visa.h`, `libvisa.so`, and - TCPIP HiSLIP support. - -If those ARM64 dependencies are not available, run S2VNA and the acquisition -server on an Ubuntu x86_64 machine and use the remote K209 mode documented -above. In that mode, Raspberry Pi runs the project pipeline and GPIO switch -drivers, while the x86_64 machine runs S2VNA and the K209 remote server. - -## Expected Hardware Test Result - -With S2VNA listening on `4880` and IVI/Vendor VISA installed correctly, the -Python and C++ smoke tests should report: - -```text -K209 IDN: Planar, K209, ... -K209 sweep OK: points=11, first_hz=10000000.000, last_hz=100000000.000 -``` diff --git a/docs/operation_modes.md b/docs/operation_modes.md deleted file mode 100644 index e4a3d50..0000000 --- a/docs/operation_modes.md +++ /dev/null @@ -1,295 +0,0 @@ -# Operation Modes - -The active radar backend is selected manually in JSON by `radar.model`. -The GUI does not expose a model selector. - -Available models: - -```text -librevna -librevna_multi -compact_m_k209 -sn9000 -kamil_adc -``` - -Example configs in the repository root: - -```text -run_config_librevna.example.json -run_config_librevna_multi.example.json -run_config_compact_m_k209.example.json -run_config_compact_m_k209_local_mock_switches.example.json -run_config_kamil_adc.example.json -run_config_simulator.example.json -``` - -## Common Commands - -Build native binaries: - -```bash -cd /path/to/radar_system -make -``` - -Run the GUI: - -```bash -.venv/bin/python -m python_app.gui.main -``` - -Run a single acquisition producer manually: - -```bash -build/bin/sweep_orchestrator --config run_config.json -``` - -The GUI process supervisor starts the correct producer automatically: - -- `librevna` -> `build/bin/sweep_orchestrator` -- `compact_m_k209` -> `build/bin/sweep_orchestrator` -- `librevna_multi` -> `python_app.scripts.matrix_raw_producer` -- `sn9000` -> `python_app.scripts.matrix_raw_producer` -- `kamil_adc` -> `python_app.scripts.kamil_adc_raw_producer` - -## Pure Simulator - -Use `run_config_simulator.example.json` to run the full GUI pipeline without -radar hardware or GPIO. It uses the single-LibreVNA mock producer, mock -switches, and the synthetic `smoke_cal` / `smoke_ref` preprocessing sets stored -under `python_app/data`. - -Typical local check: - -```bash -cd /path/to/radar_system -make -.venv/bin/python -m python_app.gui.main -``` - -Then load `run_config_simulator.example.json` in the GUI and press Start. - -## Single LibreVNA - -Use this mode when one LibreVNA is connected directly over USB to the machine -running the project. - -Config: - -```json -"radar": { - "model": "librevna", - "serial": "", - "driver_mode": "native" -} -``` - -Notes: - -- Empty `serial` means use the first compatible LibreVNA found. -- Set `serial` when multiple LibreVNAs are connected. -- `driver_mode: "native"` uses the direct USB LibreVNA driver. -- `driver_mode: "mock"` generates synthetic radar data for UI/development. -- Switch GPIO is controlled by the same machine unless switch `driver_mode` is - set to `mock`. - -Typical local check without GPIO: - -```bash -cp run_config_librevna.example.json /tmp/librevna_mock_switches.json -# edit both switches to driver_mode="mock" if needed -build/bin/sweep_orchestrator --config /tmp/librevna_mock_switches.json -``` - -## LibreVNA Multi-Device - -Use this mode for one master LibreVNA and two slave LibreVNAs. This mode does -not use physical RF switch GPIO in the acquisition producer. It exposes a fixed -virtual matrix: - -```text -inputs: 0..3 -outputs: 0..1 -combos: 8 -``` - -Config: - -```json -"radar": { - "model": "librevna_multi", - "serial": "MASTER_SERIAL", - "driver_mode": "native", - "multi_device": { - "slave_serials": [ - "SLAVE_SERIAL_1", - "SLAVE_SERIAL_2" - ], - "force_external_reference": true, - "recovery_attempts": 3 - } -} -``` - -Notes: - -- Exactly two slave serials are required. -- `force_external_reference` configures the synchronized reference workflow. -- `recovery_attempts` controls reopen/retry attempts after native acquisition - errors. -- The Python producer is selected automatically by the GUI. Manual raw-producer - run: - -```bash -.venv/bin/python -m python_app.scripts.matrix_raw_producer \ - --config run_config_librevna_multi.example.json -``` - -## Compact-M K209 On The Same Computer - -Use this for local development on the x86_64 computer that runs S2VNA and has -the K209 connected over USB-C. GPIO can be disabled with mock switches. - -1. Start S2VNA and enable HiSLIP on port `4880`. - -2. Start the local project K209 server: - -```bash -.venv/bin/python -m python_app.scripts.k209_remote_server \ - --host 127.0.0.1 \ - --port 50209 -``` - -3. In another terminal, smoke-test the server: - -```bash -.venv/bin/python -m python_app.scripts.k209_remote_smoke_test \ - --host 127.0.0.1 \ - --port 50209 -``` - -4. Run one acquisition with mock switches: - -```bash -build/bin/sweep_orchestrator \ - --config run_config_compact_m_k209_local_mock_switches.example.json -``` - -This mode is useful on a laptop because it avoids GPIO dependencies. - -## Compact-M K209 With Raspberry Pi GPIO - -Use this for the real K209 + Raspberry Pi setup: - -```text -K209 --USB-C--> x86_64 computer running S2VNA -x86_64 computer --Ethernet--> Raspberry Pi 5 -Raspberry Pi 5 --GPIO--> RF switches -``` - -On the x86_64 computer: - -```bash -cd /path/to/radar_system -.venv/bin/python -m python_app.scripts.k209_remote_server \ - --host 0.0.0.0 \ - --port 50209 -``` - -On the Raspberry Pi, set `radar.remote_host` to the Ethernet IP address of the -x86_64 computer: - -```json -"radar": { - "model": "compact_m_k209", - "remote_host": "192.168.1.10", - "remote_port": 50209, - "driver_mode": "native" -} -``` - -Then run the GUI or producer on the Raspberry Pi: - -```bash -.venv/bin/python -m python_app.gui.main -``` - -For a command-line connection check from the Raspberry Pi: - -```bash -.venv/bin/python -m python_app.scripts.k209_remote_smoke_test \ - --host 192.168.1.10 \ - --port 50209 -``` - -The Raspberry Pi does not need S2VNA or NI-VISA in this remote mode. - -## Kamil ADC With Laser Control - -Use this mode for the ADC collector from `/home/europa/Documents/kamil_adc` -and the laser board configured through `laser_control`. The external -`kamil_adc` project is not modified by `radar_system`; the producer launches -the configured executable and reads its TTY stream. - -Config: - -```json -"radar": { - "model": "kamil_adc", - "serial": "kamil_adc", - "driver_mode": "native", - "kamil_adc": { - "project_dir": "/home/europa/Documents/kamil_adc", - "executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture", - "tty_path": "/tmp/ttyADC_data", - "args": [ - "profile:phase", - "clock:internal", - "internal_ref_hz:2000000", - "mode:diff", - "channels:2", - "ch1:2", - "ch2:3", - "do1_toggle_per_frame", - "do1_pair_subtract_avg" - ] - }, - "laser_control": { - "enabled": true, - "port": "/dev/ttyUSB0", - "mode": "variation" - } -} -``` - -Notes: - -- `executable_path` is mandatory and must name the real Raspberry Pi binary. -- The producer appends `tty:` automatically; do not put `tty:` in - `radar.kamil_adc.args`. -- The producer derives the sweep point count from the Kamil ADC TTY stream. - `radar.sweep.start_hz` and `stop_hz` define the synthetic frequency axis; - `radar.sweep.points` is not a Kamil ADC setting. -- The laser-control driver is vendored under `python_app.hardware_full.laser_control`. -- `laser_control` and `kamil_adc` are treated as one hardware configuration. - Changing either section requires restarting acquisition so the lasers are - configured before the ADC collector starts. -- The TTY frame `0x000A step data1 data2` is imported as `S21 = data1 + j*data2`. - `S11` is filled with explicit zeros. - -Manual raw-producer run: - -```bash -.venv/bin/python -m python_app.scripts.kamil_adc_raw_producer \ - --config run_config_kamil_adc.example.json -``` - -## K209 Remote Performance - -The remote K209 path keeps one persistent TCP connection open. Configuration -sends sweep settings once and receives the frequency axis once. Each sweep then -sends one command byte and receives only binary `S11` and `S21` `float32` -arrays. - -Use wired Ethernet. Wi-Fi works for tests but adds jitter. diff --git a/docs/reliability_audit.md b/docs/reliability_audit.md deleted file mode 100644 index 43ccd59..0000000 --- a/docs/reliability_audit.md +++ /dev/null @@ -1,356 +0,0 @@ -# radar_system reliability audit - -Source: multi-agent audit (98 agents). 86 findings, 72 confirmed, 57 after dedup. - -## Top risks - -- Headless appliance has NO self-healing: a crashed/exited C++ child (sweep_orchestrator, data_preprocessor, data_processor) is logged once and dropped, never respawned, and because the Python GUI parent stays alive systemd Restart=on-failure never fires. Combined with headless start-failures being swallowed (exit code stays 0), the box goes dark and stays dark until a human power-cycles. This is the single most important gap to close (PS-001 / RS-02 / RAD-002). -- Multiple consumer run-loops crash the whole daemon on ONE bad/edge-case collection: data_preprocessor and data_processor have no per-iteration try/catch, and pop()/deserialize throw on torn or oversized payloads (incl. an unbounded reserve() OOM from a wire-supplied u32 count). One malformed frame permanently stops all downstream processing. Wrap per-collection bodies in try/catch + drop, make pop() resync instead of throw, and bounds-check declared counts. -- The lock-free SHM ring has real data-corruption races: producer publishes the new sequence BEFORE copying the payload and there is no post-copy re-validation (C++ pop and the Python ShmRingReader), so a lapping producer hands consumers torn payloads; full-ring overflow also silently overwrites unread sweeps and still returns success. Add a seqlock-style publish/verify protocol and surface drops. -- Device-absent-at-boot is mishandled per-path: the C++ sweep_orchestrator and the kamil_adc producer exit on first open failure with no wait-for-device retry, while backend_mode='auto' silently and PERMANENTLY latches to synthetic mock data — so the appliance records fake radar instead of waiting. Make all producers wait-for-device with bounded backoff and forbid auto->mock latching in hardware deployments. -- Disk/fd/thread exhaustion over days of uptime: child stdout/stderr logs grow with no rotation (can fill the SD card and corrupt everything), partial-open in MultiDeviceVnaController leaks a libusb context + RX thread on every forever-retry, and locator client sessions are only reaped on new accept() so flapping clients leak fds+threads. Cap/rotate logs, free partial opens, and reap sessions from publish(). -- Stale /dev/shm and orphaned children defeat restart: no production code ever unlinks rings, so a geometry change wedges the pipeline in a boot loop (C++ throws 'geometry mismatch') while Python silently truncates and diverges; a kill -9'd GUI orphans C++ children that keep holding the radar/port/rings and the next start spawns a conflicting second set. Clean rings on headless start and reap pre-existing pipeline processes via pidfile/process-group. - -## Issues (ranked) - -### #1 [HIGH] Crashed C++ pipeline child is never restarted and the failure is invisible; systemd Restart never fires because the GUI parent stays alive -- **subsystem:** py_orchestration / cross_cutting -- **location:** python_app/orchestration/process_supervisor.py:289-317; python_app/gui/controllers/app_window_pipeline_mixin.py:289 -- **impact:** collect_exit_reports() is the only place a child death is observed; on exit it logs one ERROR, sets status='error', and pops the handle from self._processes. There is no watchdog and no respawn anywhere. If sweep_orchestrator/data_preprocessor/data_processor crashes (device hiccup, OOM, segfault) acquisition/processing never resume. systemd Restart=on-failure is on the parent only, and the parent stays healthy, so it never fires. On a headless Pi the appliance silently produces no data until the next reboot. Merges PS-001 and the cross_cutting child-crash finding. -- **fix:** In headless mode treat an unexpected child exit as recoverable: from _poll_rings respawn the crashed stage with bounded retry/backoff (track restart counts per name); after exhausting retries either expose a hard 'pipeline degraded' state or os._exit(non-zero) after logging to stderr so systemd Restart=on-failure performs a clean full recovery. Add a heartbeat that detects 'expected running but all acquisition children dead'. - -### #2 [CRITICAL] Headless auto-start failures are swallowed (exit code stays 0), leaving an idle zombie daemon that never auto-restarts -- **subsystem:** deploy_daemon -- **location:** python_app/gui/app_window.py:503 (and :490); python_app/gui/controllers/app_window_pipeline_mixin.py:151-155; app_window.py:289-292 -- **impact:** In headless mode the auto-start chain catches Exception and calls _show_exception/_show_error, both of which return immediately when RADAR_SYSTEM_HEADLESS=1. The Qt loop keeps running and the process exit code stays 0. So a missing device at boot, a busy SHM ring, or a producer that fails to spawn leaves the daemon alive doing nothing; systemd sees a healthy Type=simple process and Restart=on-failure NEVER fires. The appliance silently produces no data with no self-healing. -- **fix:** In headless mode propagate fatal auto-start/apply failures into a non-zero process exit (QApplication.exit(1) / os._exit(1) after logging to stderr) so systemd restarts the unit. Add a watchdog: if the pipeline is not producing data within N seconds of headless auto-start, exit non-zero so Restart=on-failure self-heals. - -### #3 [CRITICAL] Per-collection exception in the preprocessor run loop crashes the daemon with no auto-respawn -- **subsystem:** cpp_preprocess -- **location:** data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp:136-149 -- **impact:** run() calls try_pop_raw_collection -> deserialize_raw_collection, preprocess_collection, publish_preprocessed_collection with no try/catch around the per-collection body. Any of these throws on normal-but-imperfect input (torn/stale ring slot magic/length errors, S21/S11 axis or point-count mismatch, slot-too-small, bad_alloc). The throw unwinds to main()'s catch (main.cpp:90) -> exit 1, and the supervisor does not respawn it (see rank 1). One malformed or transiently-mismatched collection permanently stops all preprocessing and every downstream result. -- **fix:** Wrap the per-iteration body (pop+deserialize, preprocess, publish) in try/catch inside run(); on std::exception log (with collection_id when available) and continue, treating one bad collection as a recoverable drop. Keep only truly unrecoverable conditions (ring not open) fatal. Optionally add a consecutive-failure counter that exits only past a threshold. - -### #4 [MEDIUM] Unbounded reserve() on a wire-supplied u32 count during raw deserialize causes OOM crash -- **subsystem:** cpp_preprocess -- **location:** data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp:136-138 (and :186) -- **impact:** read_trace_collection/read_trace_block do reserve(trace_count) and reserve(point_count) using raw uint32 counts read straight from the wire BEFORE any bytes-available check. A torn/corrupt ring slot or buggy producer can present a count near 2^32; reserve(4e9) of vector requests ~32GB and throws length_error/bad_alloc or trips the OOM killer on a 1-4GB Pi, killing the daemon (compounds rank 3). The payload_size<=slot_size_bytes check bounds the buffer but not the declared element count. -- **fix:** Before reserving, cap counts against reader.remaining_bytes(): require point_count*bytes_per_point (>=12B/point: 4 freq + 8 complex) <= remaining_bytes() and trace_count <= remaining_bytes()/min_trace_bytes; throw a descriptive runtime_error if exceeded (then caught by rank-3's loop guard) rather than reserving blindly. - -### #5 [HIGH] push() torn-write race: reader copies a slot the producer is mid-overwriting; no post-copy sequence re-check (C++ pop and Python reader) -- **subsystem:** ipc_shm / py_orchestration -- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:303 (pop check l.325); python_app/orchestration/shm/ring_reader.py:46-65 -- **impact:** push() writes payload_size, sets slot->sequence = write_seq+1, THEN memcpy's the payload, and only afterward publishes write_seq. A reader sitting on the same physical slot index can observe the NEW sequence (passing the sequence==read_seq+1 check) yet copy a mix of old+new payload bytes. The single fence between memcpy and write_seq.store does not protect a reader already inside the slot, and neither the C++ pop nor the Python ShmRingReader re-validates the sequence after copying. On a Pi where the C++ producer outruns the 50ms GUI poll, a lapping producer yields torn payloads that crash/garble deserialize/decode. Merges SHM-001 and the Python ring-reader race. -- **fix:** Seqlock-style publish: write payload+size FIRST, then publish slot->sequence with a release store; readers re-read the slot sequence AFTER copying (acquire) and discard+resync if it changed or if the writer advanced past read_seq+capacity. Equivalently use a per-slot odd/even generation counter. Also sanity-bound payload_size <= slot_size_bytes in the Python reader before slicing. - -### #6 [HIGH] backend_mode='auto' silently and permanently latches to synthetic mock data when the device is absent -- **subsystem:** py_hardware -- **location:** python_app/hardware_full/multi_device_service.py:79-83 (open), :69/:109 (latch) -- **impact:** When MultiDeviceLibreVnaService.open() fails in 'auto' mode it sets _using_mock_backend=True and swallows the error; the matrix factory passes backend_mode=config.radar.driver_mode, so driver_mode='auto' makes _open_radar_with_retry succeed immediately with synthetic data and never enter the wait-for-device loop. The flag latches permanently (open/recover short-circuit), so even after the real VNA is plugged in the service emits fabricated S-parameters forever. A headless box that boots before the VNA is connected silently records/serves completely fake radar with no operator-visible error. -- **fix:** Do not let 'auto' fall back to mock for a hardware producer meant to wait for the device. Either require driver_mode in {native,mock} for matrix producers (reject auto), or on auto-fallback emit a loud throttled WARNING and re-attempt native on every open() without latching, or treat native open failure as retryable so _open_radar_with_retry keeps waiting. - -### #7 [HIGH] Native LibreVNA sweep uses a single 1500ms deadline for the entire multi-point sweep, guaranteeing timeouts and reconnect churn on large sweeps -- **subsystem:** cpp_acquisition -- **location:** data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp:285 (used at :290) -- **impact:** acquire_native computes deadline = now + 1500ms once before the receive loop and reuses it across all sweep.points. Real dwell is ~points/IFBW seconds (e.g. 1001 points @1kHz IFBW ~= 1s + USB latency); at low IFBW/high points the sweep exceeds 1500ms. Once the shared deadline passes mid-sweep, wait_for_packet throws 'Timeout' (retryable), so acquire_sweep tears down and reconnects (lifecycle.cpp:153-160), retries the same too-short window up to 3 times, then rethrows -> no collection published and process exits 1. Native acquisition is effectively broken for any sweep longer than 1.5s, manifesting as reconnect churn then a crash. -- **fix:** Derive the deadline from configured sweep size (base + points/IFBW + margin) or extend it as progress is made (advance on each new datapoint, with an overall hard cap and a per-gap stall timeout). Never share one fixed wall-clock budget across an unbounded number of points. - -### #8 [HIGH] Transient device read/decode errors crash sweep_orchestrator with exit 1 and it is never restarted -- **subsystem:** cpp_acquisition -- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:127 (main catch main.cpp:165-168) -- **impact:** run() lets any exception propagate to main -> exit 1. Many recoverable-in-spirit faults are fatal and not in the retryable list: a single corrupted USB frame ('Invalid LibreVNA packet CRC', transport.cpp:333); a cable replug yielding a retryable bulk error followed by non-retryable 'No compatible LibreVNA USB device found' on reconnect (transport.cpp:122); a K209 socket timeout treated as a hard read failure. The supervisor never respawns sweep_orchestrator (see rank 1), so any transient device fault permanently stops acquisition on the headless box. -- **fix:** Add a bounded backoff-based supervised retry around the acquisition loop that, on recoverable device errors (timeouts, NACK, transient USB/socket, transient device-not-found after replug), closes/reopens drivers and continues in continuous mode rather than exiting. Reserve exit-1 for genuinely fatal/config errors; distinguish exit codes and/or enable supervisor respawn for the orchestrator. - -### #9 [MEDIUM] C++ sweep_orchestrator open_all() has no wait-for-device retry; absent device at boot kills the daemon -- **subsystem:** py_hardware / cpp_acquisition -- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:111-114 (open path lifecycle.cpp:102-114) -- **impact:** run() calls lifecycle_guard.open_all() exactly once; LibreVnaMinimalDriver::open() calls open_native() once and throws on failure with no retry, unwinding to main -> exit 1. For the 'librevna' and 'compact_m_k209' C++ paths, a device absent at boot (the normal Pi cold-boot race) terminates acquisition immediately. The native acquire path has only a small bounded in-loop reconnect (no unbounded wait), so a device unplugged longer than kNativeAcquireMaxAttempts also exits. This is the same robustness asymmetry the Python matrix producer was fixed to avoid. -- **fix:** Add a wait-for-device retry around open_all() mirroring matrix_raw_producer._open_radar_with_retry: loop open_all() with capped exponential backoff while !should_stop(stop_requested), logging throttled failures, proceeding only once open succeeds. Extend native acquire reconnect to keep retrying (interruptible by stop_requested) in continuous mode instead of giving up after kNativeAcquireMaxAttempts. - -### #10 [HIGH] kamil_adc producer has no open/reconnect retry and silently exits 0 on a partial sweep -- **subsystem:** py_hardware / cross_cutting -- **location:** python_app/scripts/kamil_adc_raw_producer.py:59-90 (acquire :79, partial-sweep break :89-90) -- **impact:** Unlike matrix_raw_producer, this opens radar/switches once with no retry; KamilAdcService.open() failure (collector not ready, TTY not created within startup_timeout_s, USB CDC-ACM not yet enumerated) propagates and the process exits. In the loop, radar.acquire() raising a TTY-closed/process-exited RuntimeError on a USB unplug or collector death is not caught, so one transient hiccup terminates the producer with no reconnect. Worse, len(traces)!=len(combos) breaks the loop and returns 0 even when stop was NOT requested, silently stopping continuous acquisition with no log. As a supervised child, systemd cannot restart it. Merges KAMIL-NO-WAIT and the cross_cutting kamil finding. -- **fix:** Mirror the matrix producer: wrap open()+acquire/switch in a reconnect-forever loop with capped backoff, interruptible by stop_requested (relaunch collector+reader on failure). Replace the unconditional break on incomplete traces with a check that exits only when stop_requested is set, otherwise reconnect+log. - -### #11 [HIGH] MultiDeviceVnaController partial-open leaks the master USB handle + RX thread on every failed open, unbounded under retry-forever -- **subsystem:** py_hardware -- **location:** python_app/hardware_full/librevna_multi_device_driver/controller.py:48-57 (close :67-75) -- **impact:** self._all_devices is assigned only after ALL connection opens succeed. If the master opens but a slave is absent (common boot case: one of three USB VNAs not yet enumerated), the except calls close(), which iterates the still-empty _all_devices and frees nothing. The opened master connection (USBContext + claimed handle + running 'librevna-usb-rx' daemon thread) leaks. matrix_raw_producer retries FOREVER with 1-10s backoff, so every retry leaks one context+handle+thread (~6/min) until RLIMIT_NOFILE/pthread limits crash the producer, defeating the wait-forever design. -- **fix:** Track opened devices incrementally so close() can free a partial open: append each LibreVnaUsbBulkConnection to self._all_devices (and set _master_device) as it is constructed, or in the except explicitly close the master and any constructed slaves before re-raising. Verify with lsof/thread count that a repeated open-failure loop holds fd/thread count flat. - -### #12 [MEDIUM] push() silently overwrites unread data on overflow yet returns success; drops are invisible to producers and consumers -- **subsystem:** ipc_shm -- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:298-310 (callers sweep_orchestrator.cpp:86, data_preprocessor.cpp:128, data_processor.cpp:29) -- **impact:** When the ring is full, push() advances read_seq, increments dropped, overwrites the oldest unread slot, and still returns true. Callers treat only false (slot-too-small) as an error, so whenever the consumer is slower than the producer (heavy processing or a stalled GUI tap) unread collections are silently discarded with no log and no backpressure. dropped_count() exists but is never read anywhere in the tree, so loss is invisible on the headless daemon and detection-critical sweeps can vanish. -- **fix:** Surface drops: periodically log dropped_count() deltas, or change push() to return {Queued, Overwrote, TooLarge} so callers WARN on Overwrote. For the primary raw->preprocessed->results path consider a blocking/backpressure push variant so detection data is never silently dropped. - -### #13 [MEDIUM] Stale /dev/shm rings are reused on restart; geometry change wedges the pipeline (C++ throws) while Python silently truncates and diverges -- **subsystem:** cross_cutting / ipc_shm -- **location:** data_acq_and_processing/sweep_orchestrator/src/main.cpp:139; shm_ring.cpp:199; python_app/orchestration/shm/ring_writer.py:35-40; deploy/install-daemon.sh:54 -- **impact:** No production code unlinks rings (unlink_ring/cleanup_known_shm only run under start.sh --clean-shm, which the unit's 'start.sh --headless --skip-build' does not pass). After a SIGKILL/OOM/crash the ring files persist with their last write_seq/read_seq and the restarted side ATTACHES. (1) If capacity/slot_size_bytes change between runs, the C++ side throws 'geometry mismatch' and the producer/processor dies every start -- an unrecoverable boot loop. (2) The Python writer instead silently truncates+reinitializes a size-mismatched stale ring, so a C++ reader mapped to the old size reads garbage. Stale seq counters also cause first-read mis-sequencing. Merges RS-03 and SHM-003. -- **fix:** Make restart self-healing: have the ring owner (orchestrator) unlink_ring() each ring name at startup before open_or_create, OR add --clean-shm to the unit ExecStart / a systemd ExecStartPre that clears radar_* shm objects. On geometry mismatch, unlink+recreate instead of throwing. Document a single owner per ring responsible for create+unlink, and align Python/C++ mismatch behavior. - -### #14 [HIGH] Acceptor thread permanently exits on EMFILE/ENFILE; locator reports running but never accepts again -- **subsystem:** cpp_processing_locator -- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:431 -- **impact:** In acceptor_loop any accept() error other than EINTR breaks the loop and the acceptor thread exits for good. Transient/recoverable errors (ECONNABORTED, EMFILE/ENFILE on fd-limit, ENOBUFS/ENOMEM) all permanently stop accepting. running_ stays true, is_running() keeps returning true, and data_processor keeps publish()ing into a server that can never get a new client. After a GUI/client restart it can never reconnect, with no log and no exit, until the whole daemon restarts. Compounds with the session fd leak (rank 17) which itself triggers EMFILE here. -- **fix:** Distinguish fatal vs transient accept() errors: on EINTR/ECONNABORTED continue; on EMFILE/ENFILE/ENOBUFS/ENOMEM log a warning, sleep ~100ms, and continue so the acceptor recovers once fds free up; only break when running_ is false or the fd is genuinely closed (EBADF/EINVAL). Optionally keep one reserved fd to accept-and-close under EMFILE. - -### #15 [MEDIUM] Uncaught exception in the data_processor live loop crashes the headless daemon -- **subsystem:** cpp_processing_locator -- **location:** data_acq_and_processing/processing/data_processor/src/data_processor.cpp:111 (publish throw :30, main catch main.cpp:87) -- **impact:** run() calls deserialize_preprocessed_collection, process_collection, and publish_result_collection (which throws when a serialized result exceeds the results ring slot) with no per-iteration try/catch. Any throw propagates to main -> exit 1, taking down the whole processing+locator stage. A single oversized/edge-case result (e.g. a large bscan replay table) or one corrupt preprocessed frame is a hard outage rather than a dropped frame, and the supervisor does not respawn it (rank 1). -- **fix:** Wrap the per-iteration body (deserialize, process, publish_result_collection, publish_locator) in try/catch that logs and continues to the next ring item. Reserve fatal exit for truly unrecoverable conditions (ring detached). For publish, log-and-drop oversized results instead of throwing. - -### #16 [MEDIUM] Child stdout/stderr log files grow without rotation; long-lived daemon can exhaust the SD card and wedge the system -- **subsystem:** py_orchestration / recent_changes / cross_cutting -- **location:** python_app/orchestration/process_supervisor.py:158-164 -- **impact:** _spawn opens runtime/logs/{name}.out.log and .err.log in 'wb' (truncate only at spawn) and hands the fds to each child. There is zero rotation/size cap anywhere in the repo. The always-on data_processor and a continuously-logging producer (per-sweep logging, repeated reconnect warnings while a device is absent, locator per-malformed-packet warnings) run for days/weeks between reboots and grow .out/.err without bound. A full rootfs on a Pi corrupts SQLite/NPZ writes and SHM/config writes and can wedge the whole system -- including the very logs needed to diagnose it. Merges PS-002, RS-06, and the recent_changes data_processor log finding. -- **fix:** Do not redirect children to plain truncating files for a long-lived daemon: pipe output through a size-bounded RotatingFileHandler-style writer, run children under systemd-journald, or periodically rotate/cap (size + count). At minimum cap each file and rotate the always-on data_processor log on a size limit; extend throttling to all hot-path warnings. - -### #17 [MEDIUM] Non-draining/dead locator client leaks fd + 2 threads forever; sessions are only reaped on new accept() -- **subsystem:** cpp_processing_locator -- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:200 (write_all :38-59; reap only at :439) -- **impact:** The latest-wins change keeps a full-queue client instead of request_stop() on overflow. With a blocking writer (write_all loops on send() with no SO_SNDTIMEO) and reaping only inside acceptor_loop, a client whose TCP window goes to zero (peer alive but not reading) blocks the writer thread forever; the reader stays blocked in recv() (peer never closes) so exited_ is never set and the session is never reaped -- leaking one fd + two threads per stuck client. Separately, normally-finished sessions also linger in clients_ until the next accept(), so with a fixed/flapping client set zombies accumulate and every publish() wastes work iterating them; this eventually triggers EMFILE -> rank 14. Merges LOC-001 and the recent_changes reap finding. -- **fix:** Set SO_SNDTIMEO on accepted sockets and treat send timeout as fatal -> request_stop(), so a stuck peer is torn down. Set exited_ when BOTH loops finish so a writer-only death is reapable. Call reap_finished_clients() from publish()/broadcast_packet() (try-lock, join outside the mutex) or a periodic timer so clients_ is bounded regardless of new connections. Tune TCP keepalive (KEEPIDLE/INTVL/CNT). - -### #18 [HIGH] NaN/Infinity float fields round-trip into run_config.json and abort every C++ consumer at boot -- **subsystem:** py_config_models -- **location:** python_app/models/run_config_codec.py:113-122 (write path config_writer.py:46) -- **impact:** All float fields are coerced with bare float(); Python json.loads accepts NaN/Infinity and float('nan')/('inf') also arise from stray strings. validate_gpr_model uses float(rel_perm) <= 0.0, always False for NaN, so a NaN permittivity passes. ConfigWriter.write() calls json.dumps with default allow_nan=True, emitting literal NaN/Infinity into run_config.json; the C++ nlohmann parser (run_config.cpp:431, default flags) throws parse_error. The moment a profile with any non-finite numeric is saved, every spawned C++ process fails to load config and exits at startup -- the appliance silently never acquires while the JSON looks valid to an operator. -- **fix:** Reject non-finite numbers at decode and encode time: add a _read_float helper that does float(...) then raises ValueError if not math.isfinite, and use it for every float() in run_config_codec.py. Independently pass allow_nan=False to json.dumps in config_writer.py:46, live_processing_config.py:142, and profile_io_mixin.py:31 so a stray NaN fails loudly in Python. - -### #19 [MEDIUM] Abrupt GUI SIGKILL orphans C++ children holding the radar/rings/port; next start spawns a conflicting second pipeline -- **subsystem:** cross_cutting -- **location:** python_app/orchestration/process_supervisor.py:77-83 -- **impact:** Clean shutdown relies on closeEvent -> _stop_all_processes. If the GUI is killed abruptly (kill -9, OOM-killer, crash skipping closeEvent), the Popen children reparent to init and keep running, still holding the USB radar handle, locator TCP port, and SHM rings. is_running()/is_processor_running() consult only the in-memory _processes dict (empty in a fresh process), so the new instance does not detect orphans and spawns a second full pipeline; two processes then contend for the same device and rings. The start.sh flock and 'systemctl stop' only cover the systemd-managed case; a kill -9'd interactive launch or any non-cgroup kill leaves orphans uncovered. -- **fix:** Detect/reap pre-existing pipeline processes at startup independent of in-memory state: write child PIDs to a runtime pidfile and kill stale ones on start, or scan for known binary names, or under systemd use KillMode=control-group and launch children in a dedicated process group killed on supervisor start. Combine with rank-13 ring cleanup. - -### #20 [HIGH] Headless boot pip install hangs/fails forever on an offline appliance, causing a crash-restart loop -- **subsystem:** deploy_daemon -- **location:** /home/europa/Documents/radar_system/start.sh:354 (failure exit :167-169) -- **impact:** main() calls ensure_python_dependencies() even in --headless mode. If the import probe fails for any reason (partially-upgraded wheel, .pyc/.so mismatch after an OS update, corrupted .venv, a new dep in requirements.txt), the daemon runs pip install. On an offline appliance pip cannot reach PyPI, blocks on DNS/connect retries (delaying the unit), then exits non-zero -> with Restart=on-failure/RestartSec=3 this becomes a crash-restart loop that never starts the radar. The headless guard at :347-353 only skips sudo/system steps, not the more likely network block. -- **fix:** In headless mode treat missing dependencies as a hard, fast failure: if the import probe fails, log a clear error and exit non-zero immediately, or gate the pip-install branch behind ((HEADLESS == 0)). Provisioning should only happen during the documented interactive launch. Optionally set PIP_NO_INDEX defensively so any accidental install fails fast instead of hanging. - -### #21 [HIGH] Daemon runs --skip-build with no validation that build/bin binaries exist and are current -- **subsystem:** deploy_daemon -- **location:** /home/europa/Documents/radar_system/deploy/install-daemon.sh:54 (skips start.sh:360-362) -- **impact:** ExecStart passes --skip-build, so the boot daemon never builds. The default librevna producer is the native build/bin/sweep_orchestrator. If that binary is missing, stale (built against a changed C++/SHM layout), or wiped by git clean/partial update, the daemon either fails to spawn the producer (silenced per rank 1/2) or runs a producer whose SHM ring format mismatches the reader -> silent no-data or corrupt data. There is no pre-flight check that required binaries exist and are newer than sources. -- **fix:** Add a fast headless pre-flight that verifies the required build/bin binaries exist and are executable (no full rebuild) and aborts with a non-zero exit if missing or older than their sources, so Restart/operator notice fires. Alternatively run make -q and fail fast on a stale tree rather than trusting --skip-build. - -### #22 [HIGH] systemd unit has no boot ordering or device-readiness gate, racing USB/local-fs at boot -- **subsystem:** deploy_daemon -- **location:** /home/europa/Documents/radar_system/deploy/install-daemon.sh:46 -- **impact:** The generated unit has an empty [Unit] section (no After=/Wants=/Requires=) and Type=simple. WantedBy=multi-user.target only sets the install target, not startup ordering against device/filesystem readiness. On a Pi the USB radar enumerates asynchronously after udev settles and the .venv/project may live on a not-yet-ready mount, so the daemon can start before the device node exists and hit 'device not found' (then silently idle per rank 2 or churn per rank 7). Type=simple also marks the service 'started' the instant exec begins, so readiness cannot be relied upon. -- **fix:** Add ordering: After=local-fs.target systemd-udev-settle.service and Wants=systemd-udev-settle.service (or a device-specific BindsTo=/After=dev-...device via a udev SYSTEMD_WANTS tag); if the project mount is non-root add RequiresMountsFor=${PROJECT_ROOT}. Consider Type=notify with sd_notify(READY=1) once the pipeline is actually producing. - -### #23 [MEDIUM] Headless daemon writes all logs/errors only to an offscreen Qt widget; nothing reaches journald -- **subsystem:** cross_cutting -- **location:** python_app/gui/app_window.py:365 (widget app_window_ui_mixin.py:181; unit deploy/install-daemon.sh:46) -- **impact:** In --headless mode every GUI-side message (startup errors, pipeline-start failures, reader-poll exceptions, child-crash exit reports, 'Status: error') is rendered via _append_log_entry into a QTextEdit on the offscreen platform. Nothing is written to stdout/stderr/journald (no logging/StreamHandler in the GUI process; the unit sets no StandardOutput/SyslogIdentifier). The widget is capped at 1200 in-memory blocks, so older errors scroll away and are lost on exit. 'journalctl -u radar.service' shows no GUI diagnostics, making a headless box undebuggable when acquisition silently stops. -- **fix:** In headless mode also route _append_log_entry (at least WARN/ERROR) to Python logging with a StreamHandler to stderr (captured by journald) and/or a rotating file under runtime/logs. Set SyslogIdentifier and StandardError=journal in the unit. Keep the widget for GUI mode. - -### #24 [LOW] SIGTERM/SIGINT handler runs full Qt teardown inline from C signal context; re-entrant and reentrancy-unsafe -- **subsystem:** py_gui_lifecycle / cross_cutting -- **location:** python_app/gui/main.py:42 (closeEvent app_window.py:512) -- **impact:** _request_shutdown directly calls window.close() -> closeEvent (which terminates C++ children with multi-second waits and closes mmaps) and app.quit() from signal context. Python delivers handlers between bytecodes on the main thread, so a second SIGTERM (systemd escalation or a double Ctrl-C) arriving during the blocking teardown re-enters _request_shutdown -> closeEvent recursively on half-torn-down state (readers None, supervisor map mutated mid-iteration), corrupting teardown ordering or raising inside the handler. There is no closeEvent re-entry guard and no signal de-arming. This is the daemon's normal shutdown path. Merges RS-001 and the cross_cutting signal-safety finding. -- **fix:** Make the handler async-signal-safe: only set a flag / write a self-pipe (signal.set_wakeup_fd + QSocketNotifier) or QTimer.singleShot(0, window.close) to schedule teardown on the next event-loop iteration, and immediately reset handlers to SIG_IGN/SIG_DFL so a repeat signal cannot re-enter. Add a self._closing guard at the top of closeEvent that returns early if teardown is in progress. - -### #25 [LOW] pop() throws on oversized payload_size and reader trusts payload_size before bounds-checking the mapping -- **subsystem:** ipc_shm -- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:331-338 (Python ring_reader.py:56,63) -- **impact:** pop() throws runtime_error when slot->payload_size > slot_size_bytes -- reachable from a torn write (rank 5) or stale/corrupt ring (rank 13) -- and the throw propagates up the preprocessor/processor run loops, killing the daemon (compounds rank 3/15). The Python reader does NOT validate payload_size at all before slicing, so a torn/corrupt size runs off the slot into adjacent slots/header and decode_* mis-parses. The size is also read non-atomically relative to the producer's write of it (rank 5), so even in normal wrap the size can belong to a different generation than the copied bytes. Merges SHM-006 and SHM-007. -- **fix:** Make pop() treat an over-size payload as a corrupt slot it skips: log, advance read_seq past it (resync), and return false instead of throwing. Validate payload_size <= slot_size_bytes in the Python reader and reject/resync otherwise. Combine with rank-5's post-copy sequence re-validation so a size/payload pair is accepted only if the slot sequence is unchanged across the read. - -### #26 [LOW] Blocking device I/O makes SIGTERM/SIGINT shutdown hang up to the full I/O timeout (~20s for K209) -- **subsystem:** cpp_acquisition -- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:117 (and :149) -- **impact:** The signal handler only sets g_stop_requested and the run loop checks it between sweeps/combos. Every per-combo step blocks in non-interruptible device I/O: native VNA sweep up to ~1500ms in libusb_bulk_transfer, DeviceInfo wait up to 2s, remote K209 ::recv up to 20000ms. On a headless Pi a SIGTERM during a stalled read is ignored for the full timeout, and a wedged device that keeps timing-out-and-retrying can effectively never honor stop, forcing the supervisor's force-kill. Clean shutdown / switch-to-safe-state is not guaranteed. -- **fix:** Make the stop flag observable inside blocking waits: pass stop_requested into the driver acquire path (or a self-pipe/eventfd woken by the handler), check it inside wait_for_packet/wait_for_ack/pump_usb and recv_exact/send_all loops, and keep per-call USB/socket timeouts short and re-loop so SIGTERM is honored within a few hundred ms. - -### #27 [MEDIUM] Oversized serialized collection makes push() return false and is escalated to a fatal crash; tap-ring failure aborts the primary path -- **subsystem:** cpp_acquisition / cpp_preprocess -- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:84-91 (also data_preprocessor.cpp:126-134) -- **impact:** push() returns false only when payload > slot_size_bytes; overflow is handled internally by overwrite-oldest. publish_collection/publish_preprocessed_collection throw on false -> exit 1. slot_size_bytes is fixed at create time but serialized size scales with run_combos x sweep.points x per-point bytes, so growing combo/point count past the slot makes EVERY collection too large and the first publish crashes the daemon at startup with no recovery. Worse, a too-small raw_tap/preprocessed_tap slot crashes the whole producer even though the primary ring already accepted the data -- a debug/GUI tap takes down the real data path. Merges SO-005 and PREP-003. -- **fix:** Validate worst-case serialized size against slot_size_bytes at open/startup and fail fast with a clear config error there. At runtime, log-and-drop (increment an oversize counter) on a too-large payload instead of throwing, and make tap pushes strictly best-effort so a tap failure can never abort the primary path. - -### #28 [LOW] ConfigWriter.write performs a non-atomic write_text of run_config.json consumed by spawning C++ children -- **subsystem:** py_config_models -- **location:** python_app/orchestration/config_writer.py:43-47 -- **impact:** write() does output_path.write_text(json.dumps(...)) directly (no temp+rename), unlike sibling writers that use temp+replace. The supervisor reads this file (_read_radar_model :222) and every C++ child reads it via --config at startup. An interrupted write (power loss mid-write, or a child reading during a restart rewrite) yields a truncated/empty file -> json.loads raises and start() aborts opaquely, or a child fails to parse. On crash mid-write the on-disk file is left corrupt and persists across reboot, so the boot daemon fails to start the pipeline every boot until manually repaired. -- **fix:** Write atomically: dump to output_path.with_suffix('.json.tmp'), flush+os.fsync, then os.replace() onto the destination (matching ProcessingLiveConfigWriter). Optionally fsync the parent dir for power-loss durability. - -### #29 [MEDIUM] Respawn opens child log files in truncate mode, destroying the prior child's crash log before it is reported -- **subsystem:** py_orchestration -- **location:** python_app/orchestration/process_supervisor.py:163-164 -- **impact:** _spawn always opens stdout/stderr with open(path,'wb') (truncate) and early-returns only if the existing handle is still alive. When a process has crashed but its exit has not yet been collected (collect_exit_reports removes it, but start() can run before the next 50ms poll, e.g. single-capture restart or operator re-Start), the next _spawn reopens 'wb' and erases the crashed child's stdout/stderr -- the diagnostic evidence of why it died is gone before anyone reads it, undermining the exit-report mechanism. Same loss across a parent restart for the previous boot's final crash log. -- **fix:** Before truncating, if a stale (exited, uncollected) entry exists for this name, fold its tail into an exit report first or roll the existing log to {name}.out.log.prev/.err.log.prev. Alternatively open in append mode with a session delimiter (paired with rotation from rank 16) so the crash log survives respawn. - -### #30 [MEDIUM] Singular OSL calibration points silently substitute degenerate coefficients; a bad calibration loads and is used -- **subsystem:** cpp_preprocess -- **location:** data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp:221-226 -- **impact:** solve_osl_coefficients initializes source_match=0, reflection_tracking=1 and only overwrites them when norm(open_delta-short_delta) > 1e-12. When open/short standards are nearly equal (a degenerate capture, common with a flaky USB VNA on a Pi) the point keeps the degenerate coefficients, making S11 correction at that frequency reduce to measured-minus-directivity with no real correction. There is no count, log, or threshold on fallbacks, and 1e-12 on a float magnitude-squared rarely trips for ill-conditioned-but-nonzero denominators. A largely-degenerate calibration loads successfully and produces systematically wrong S11 with no operator-visible indication. -- **fix:** Track the fraction of fallback points per combo; throw at load time if it exceeds a small threshold so a bad bundle is rejected at startup instead of silently used. Use a relative (not just absolute) conditioning check on the denominator and log which combos/frequencies were degenerate. - -### #31 [MEDIUM] libusb retry path does full libusb_exit/init churn per recovery; a recoverable USB glitch becomes a fatal device-not-found -- **subsystem:** cpp_acquisition -- **location:** data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp:157-159 (transport.cpp:111,171) -- **impact:** On a retryable native error, acquire_sweep calls close_native() (libusb_exit) then open_native() (libusb_init), destroying and recreating the entire libusb context and re-enumerating all USB devices per transient stall. On a Pi this re-enumeration is slow and racy right after a replug: the kernel may not have re-bound the device, so find_matching_device_handle returns null and open_native throws the non-retryable 'No compatible LibreVNA USB device found', turning a recoverable glitch into a fatal exit (compounds rank 8). Repeated init/exit cycling also stresses libusb on a long-running daemon. -- **fix:** Keep the libusb_context alive across retries; only release/reclaim the interface and reopen the handle, not the whole context. On reconnect retry device discovery with a short bounded backoff (a few hundred ms, a few attempts) to absorb re-enumeration latency, and classify 'device not found immediately after a transient error' as retryable. - -### #32 [MEDIUM] multi_device recover() uses blocking time.sleep and ignores stop_requested, delaying SIGTERM shutdown by seconds per failed acquisition -- **subsystem:** py_hardware -- **location:** python_app/hardware_full/multi_device_service.py:114-139 (controller :169) -- **impact:** recover() sleeps through _REOPEN_BACKOFF_SECONDS (0.25+0.5+1.0=1.75s) with no stop hook, and _acquire_native_collection_with_recovery calls recover() up to recovery_attempts+1 (default 4) per acquire_collection(). One failed acquisition can block ~4 x (1.75s + open/close) before stop_requested is re-checked. On device removal + SIGTERM the producer can take tens of seconds (with USB re-enumeration) to exit, risking systemd TimeoutStopSec SIGKILL and an unclean shutdown; the signal handler only sets a threading.Event these C-level/sleep sections never observe. -- **fix:** Thread the stop Event into recover() and _acquire_native_collection_with_recovery; use stop_event.wait(delay) instead of time.sleep and bail out of both the backoff and recovery-attempt loops the moment stop is set. Cap total recovery wall-time per acquire_collection() so shutdown stays well under TimeoutStopSec. - -### #33 [LOW] stop() force-kills on a shared 2s deadline, drops handles without exit reports, and may orphan device-I/O grandchildren -- **subsystem:** py_orchestration -- **location:** python_app/orchestration/process_supervisor.py:230-261 -- **impact:** _stop_processes terminates all named processes against a single shared 2.0s deadline, kills stragglers, then _drop_exited() removes handles with NO ProcessExitReport. (1) The librevna_multi/sn9000/kamil producers are launched as 'python -m python_app.scripts...'; SIGTERM/SIGKILL to that python parent does not necessarily kill device-I/O grandchildren/threads, so a hung device thread can be orphaned holding the VNA/USB device and make the NEXT start() fail to acquire it. (2) Any abnormal exit during stop (e.g. processor segfault on teardown) is silently swallowed, so recurring shutdown crashes are invisible. -- **fix:** Use start_new_session=True (process group) on Popen for the python producer commands and os.killpg on stop so grandchildren die. Give each process its own kill deadline rather than a shared 2s budget. Before _drop_exited, capture exit codes and log abnormal stop-time exits (or route through collect_exit_reports). - -### #34 [LOW] Latest socket vlc speed never expires; a dropped client's last speed is used indefinitely as live motion -- **subsystem:** recent_changes / cpp_processing_locator -- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:280 (read :414-420) -- **impact:** reader_loop stores any finite inbound vlc into a shared atomic that latest_socket_speed() returns forever until the next value or process restart; there is no timestamp/TTL and the value is not reset when the contributing client disconnects. If the speed feed (odometer/positioning) drops or freezes, the GPR pipeline keeps consuming the last speed as if live, silently producing migration/positioning results from stale motion with no indication the feed died. -- **fix:** Store (value, monotonic timestamp) and have latest_socket_speed() return nullopt once older than a configured staleness window so the processor falls back to manual speed or flags missing data. Optionally reset the slot to NaN when the last contributing client disconnects. - -### #35 [LOW] GPIO control-button watcher leaks line/chip/pipe fds and an orphaned thread on partial start failure, wedging the button until reboot -- **subsystem:** py_config_models / recent_changes -- **location:** python_app/gui/control_button.py:87-94 (mixin app_window_control_button_mixin.py:55-60) -- **impact:** start() opens the GPIO line, then os.pipe(), then starts the daemon thread, with no rollback. If os.pipe() (fd exhaustion) or Thread.start() fails after _line.open() succeeded, start() raises with the GPIO chip+line fds (and possibly pipe fds) still open; the mixin's except only logs and _control_button_watcher stays None, so _stop_control_button_watcher can never release them. The kernel line stays claimed (consumer='radar_control_button'), so a later retry/restart hits EBUSY and the button silently never works again until reboot. The _run loop also leaks fds on any select/read error. Merges the two GPIO-watcher leak reports. -- **fix:** Wrap start()'s body in try/except that calls _line.close() and _close_stop_pipe() before re-raising, and close/release in a finally in _run (or have the failed handler trigger stop). Guard start() against double-start. Alternatively assign self._control_button_watcher before start() (or in finally) and call _stop_control_button_watcher() in the except path. - -### #36 [LOW] Non-positive/out-of-range ring capacity, slot_size, and sweep points pass Python validation and crash C++ at boot -- **subsystem:** py_config_models -- **location:** python_app/models/run_config_validation.py:49-50; run_config_codec.py:118 -- **impact:** load_ring_payload coerces capacity/slot_size_bytes with bare int() and no range check, and radar.sweep.points is int()-coerced with no check. A profile with capacity 0/-1, negative slot_size, or points<=0 is accepted and written to run_config.json. The C++ side throws ('Ring capacity must be > 0', 'Value out of uint32 range', 'radar.sweep.points must be > 0') and every pipeline process aborts at startup -- a recurring boot-time crash with no acquisition until the config is hand-edited. Python int(100.5)=100 also accepts a fractional points value that C++ number_to_u32 rejects, so a profile that loads in the GUI still fails in C++. Merges CFG ring and points validation findings. -- **fix:** In Python enforce capacity > 0, slot_size_bytes > 0 (with a uint32 upper bound and an overflow-safe cap on capacity*slot_size), and radar.sweep.points > 0; reject fractional points (require integral input) and validate stop_hz >= start_hz, mirroring the C++ contracts so failures surface in the GUI/save path. - -### #37 [LOW] Explicit JSON null in numeric/bool config fields is silently coerced or hard-fails instead of using the default -- **subsystem:** py_config_models -- **location:** python_app/models/run_config_codec.py:32 (and the int()/float()/bool() call sites) -- **impact:** _read_str guards strings against null (payload.get returns None for explicit null; str(None)='None'), but numeric/bool fields still use int()/float()/bool() directly. With explicit null, int(None)/float(None) raise TypeError (bypassing the intended default fallback) and bool(None)=False silently overrides a True default -- e.g. multi_device.force_external_reference (default True) and control_button.active_low (default True). A profile with 'force_external_reference': null quietly disables the external reference and 'active_low': null flips the button edge polarity. -- **fix:** Generalize null-as-missing handling: add _read_int/_read_float/_read_bool helpers mirroring _read_str that treat None as 'use default', and apply them wherever int()/float()/bool() wrap payload.get(). This prevents the TypeError on null numerics and stops null from silently flipping a True default to False. - -### #38 [LOW] Empty S11 calibration/reference paths silently disable correction with no operator warning -- **subsystem:** cpp_preprocess -- **location:** data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp:303-305 (and :386-388) -- **impact:** S11CalibrationBundle::load returns early (correction disabled) when all open/short/load paths are empty, and S11ReferenceBundle::load returns early when path is empty; apply() then passes S11 through uncorrected and validate_combos skips validation when not enabled. A config typo resolving an S11 path to empty (or a missing key defaulting to '') silently disables one-port S11 correction: the box boots, runs headless, and emits uncorrected S11 with no error or warning until measurement quality is questioned much later. -- **fix:** Distinguish intentionally-disabled from misconfigured: require an explicit s11.calibration.enabled flag to disable (no-op when flagged), but when a path is expected and resolves empty/missing, throw at load so startup fails loudly. At minimum log a clear WARNING to stderr (visible in the per-process log). - -### #39 [LOW] _poll_rings exception handler dedups by (type,str), permanently silencing distinct recurring reader failures -- **subsystem:** py_gui_lifecycle -- **location:** python_app/gui/controllers/app_window_pipeline_mixin.py:319-324 -- **impact:** When _poll_rings raises, it logs once per unique (type, message) and suppresses every identical exception thereafter. A persistent reader fault (SHM ring detached after a producer crash, repeated 'Result ring reader is not initialised') is logged once then silently swallowed every 50ms forever; the status label is set to error only via collect_exit_reports, not here, so the operator sees no continuing signal that polling/rendering is dead -- the screen simply stops updating. There is also no recovery attempt (readers are never reset/reconnected). -- **fix:** Keep dedup for log spam but still set the status label to error on a repeated reader error, periodically re-log (every N seconds or count), and trigger a reader-reconnect or pipeline-stop path so a wedged reader is surfaced and recovered rather than failing silently. - -### #40 [MEDIUM] Blocking hardware capture and time.sleep drain loops run on the GUI/event-loop thread, freezing the headless daemon and starving signal delivery -- **subsystem:** py_gui_lifecycle -- **location:** python_app/gui/controllers/app_window_control_button_mixin.py:73 (drains app_window_pipeline_mixin.py:417, snapshot_mixin.py:236) -- **impact:** _on_control_button_pressed -> _capture_tmp_reference runs entirely on the Qt main thread: it calls _stop_run() (with time.sleep drain loops) and capture_reference_set() which opens the device and acquires median_sweep_count sweeps synchronously. During this the event loop is blocked, so the 50ms _poll_rings stops draining SHM rings (rings fill/overwrite), the headless keepalive timer that delivers Unix signals stops firing, and queued button signals stall. The bounded drain loops (~0.6s stop, ~0.35s clear, ~1.2s snapshot) compound this on the closeEvent path, widening the signal-reentrancy window (rank 24). A physical button press produces a multi-second total UI/daemon stall and delays SIGTERM. Merges the capture-on-GUI-thread and drain-loop findings. -- **fix:** Run capture off the GUI thread (QThread/worker, results marshaled via queued signal) and guard re-entrant presses with a busy flag. Convert the bounded drain loops to event-loop-friendly waits (QEventLoop+QTimer or a worker) so signals and the keepalive timer keep firing, or aggressively cap/avoid blocking drains on the closeEvent path. - -### #41 [LOW] RF switches are not driven to a safe/default state on shutdown or crash; a transient ioctl failure is fatal -- **subsystem:** cpp_acquisition -- **location:** data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp:136 (hmc349a :119; hot-loop switch_to sweep_orchestrator.cpp:156-157) -- **impact:** open() drives switches to default_position, but close_native() only releases the GPIO fds and never returns the lines to the safe position, so on exit (clean SIGTERM, crash, or exit-1) the RF front-end is left in an undefined electrical state between runs. Worse, switch_to() in the hot loop is unguarded: a single GPIO_V2_LINE_SET_VALUES ioctl failure throws, is in no retry path, and kills the whole daemon, leaving the matrix switches in whatever state they were last commanded. -- **fix:** In close_native() command lines to default_position before closing fds so the RF path is left known-safe; ensure the DriverLifecycleGuard destructor also drives switches safe. Wrap per-combo switch_to() in the same recoverable-error handling as device reads so a transient ioctl failure retries/reconnects instead of crashing. - -### #42 [LOW] USBTransport.disconnect() closes the libusb handle/context even when the RX-thread join times out (use-after-free hazard) -- **subsystem:** py_hardware -- **location:** python_app/hardware_full/librevna_driver/transport/usb.py:155-174 -- **impact:** disconnect() sets the stop event, joins the RX thread with a 1.0s timeout, then unconditionally releaseInterface()/close()es the handle and closes the context even if the join TIMED OUT and the thread is still inside a blocking bulkRead. Closing the USBContext/handle out from under a live RX thread is a use-after-free / libusb-state-corruption hazard that on a Pi can hang or crash during reconnect; under retry-forever, any RX thread that fails to exit within 1s raises the odds of an orphaned daemon thread referencing a closed context. -- **fix:** After join(timeout=1.0) check rx_thread.is_alive(); if still alive, log a hard fault and either retry the join with a longer bound or skip closing the handle/context (deliberate leak is safer than closing under a live thread). Better: ensure the 100ms bulkRead + stop_event check guarantees exit, and assert the join succeeded before closing. - -### #43 [LOW] GpioOutputLines/GpioLineEventWatcher close() can raise from os.close and leave the second fd open -- **subsystem:** py_hardware -- **location:** python_app/hardware_full/switch_drivers/gpio_uapi.py:182-185 (watcher :318-321) -- **impact:** close() calls _close_line_fd() then _close_chip_fd() sequentially with no exception isolation. If os.close(line_fd) raises (EINTR, or EIO/ENODEV when a USB GPIO expander is yanked on a Pi), the exception propagates and _close_chip_fd() never runs, leaking the chip fd; the chip fd is also never reset to -1, so a later reopen overwrites/leaks it. Over many switch open/close cycles in a long-running daemon this slowly exhausts fds. -- **fix:** Make close() best-effort and idempotent: wrap each os.close in try/finally (or contextlib.suppress(OSError)) so both _close_line_fd and _close_chip_fd always run and always reset their fd to -1 even when close() errors. Apply to both classes. - -### #44 [LOW] matrix producer's radar.close() in finally can block SIGTERM-driven shutdown on a hung device -- **subsystem:** recent_changes / py_hardware -- **location:** python_app/scripts/matrix_raw_producer.py:154-157 -- **impact:** On SIGTERM the loop breaks and finally calls radar.close() under suppress(Exception). For SN9000 (VISA/TCP) or LibreVNA (libusb), close() can issue a blocking transport teardown that hangs when the device is unresponsive -- exactly the failure this producer tolerates -- and suppress() does not bound time. With the supervisor's ~2s pre-SIGKILL budget, a hung close() means force-kill; and if acquire_collection() is mid-blocking-read when the signal arrives, the Python handler cannot interrupt the C-level call, so stop_requested is observed only after it returns, delaying clean exit up to the device timeout and risking SIGKILL mid-sweep (partial device state). -- **fix:** Bound device teardown: run radar.close() with a watchdog/timeout (timer thread or hard deadline) so a hung transport cannot delay exit, and ensure the driver's blocking acquire uses a finite transport timeout so stop_requested is checked at bounded intervals. - -### #45 [LOW] KamilAdcService.open()/_wait_for_tty busy-polls and ignores the stop Event, delaying shutdown during the boot startup window -- **subsystem:** py_hardware -- **location:** python_app/hardware_full/kamil_adc_service.py:419-430 -- **impact:** _wait_for_tty polls with time.sleep(0.05) up to startup_timeout_s with no reference to the producer's stop_requested Event. If the collector is slow to create the TTY (or never does) at boot and the operator sends SIGTERM during this window, the producer cannot interrupt the wait and must block until startup_timeout_s elapses before unwinding. Combined with the lack of an open() retry loop (rank 10), startup is the least responsive phase to a stop request, adding to worst-case TimeoutStopSec pressure. -- **fix:** Accept an optional stop Event in open()/_wait_for_tty and break the poll loop promptly when set (event.wait(0.05) instead of time.sleep). Have kamil_adc_raw_producer pass its stop_requested Event through so shutdown is immediate in all phases. - -### #46 [LOW] Crashed-child exit reporting reads both 16KB log tails on the GUI thread every 50ms; stop() blocks the GUI for seconds -- **subsystem:** py_orchestration -- **location:** python_app/orchestration/process_supervisor.py:240-253 (tails :299-300) -- **impact:** collect_exit_reports (called every 50ms from the GUI QTimer) does a 16KB seek+read on each exited child's stdout AND stderr (SD-card I/O from the UI loop). More significantly, _stop_processes blocks the calling thread up to ~2s (terminate deadline) + up to 1s per force-killed process; stop_all on three stuck children freezes the GUI ~3-5s. On the headless box the GUI is the supervising loop, so during a stop the 50ms ring poll stalls, exit reports are not collected, and any added watchdog is starved. -- **fix:** Move process termination/wait off the GUI thread (worker thread or QProcess async finished signals) or cap the total stop budget. Read log tails lazily only when actually building an ERROR report, not on every 50ms poll for every exited process. - -### #47 [LOW] load_channel_traces silently collapses duplicate combos via insert_or_assign, hiding bundle corruption -- **subsystem:** cpp_preprocess -- **location:** data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp:142 -- **impact:** load_channel_traces builds traces_by_combo with insert_or_assign for every trace. If a calibration/reference bundle contains two traces for the same ComboKey (a generation bug or a partially-overwritten/corrupted bundle), the second silently overwrites the first. The operator believes a calibration is loaded for that combo when it is actually an arbitrary last-wins duplicate, potentially the wrong standard; this passes all combo-coverage validation and is undetectable at runtime. -- **fix:** Use insert() and check the bool result; on a duplicate ComboKey throw a descriptive runtime_error ('duplicate combo X in bundle') so a malformed bundle is rejected at load time. - -### #48 [LOW] Stale-but-running locator keeps emitting sts=1 with a fresh timestamp; clients cannot tell processing has stalled -- **subsystem:** cpp_processing_locator -- **location:** data_acq_and_processing/processing/locator/src/payload_builder.cpp:184 -- **impact:** build_payload_json hardcodes sts=1 and a fresh wall-clock tim on every packet. The snapshot-on-connect sends the last cached packet to new clients, and publish() is only driven by data_processor frames. If the processor loop stalls or exits, the locator keeps the last cached packet, and any newly connecting client receives a packet that always claims sts=1 with stale observations -- there is no liveness/heartbeat or staleness indication, so a downstream consumer cannot distinguish live data from a frozen pipeline. -- **fix:** Carry a real status/age signal: stamp packets with the source frame time so consumers can detect staleness, or emit a heartbeat with sts reflecting whether a fresh result was produced within a recent window. At minimum do not re-send a stale cached snapshot to a new client without marking it stale. - -### #49 [MEDIUM] Tap/overflow rings have two concurrent plain-store writers to read_seq -> lost-update race -- **subsystem:** ipc_shm -- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:299 (Python ring_reader.py:59,64,95) -- **impact:** The ring is single-producer/single-consumer, but on the tap rings (raw_tap, preprocessed_tap) BOTH sides write read_seq concurrently: the C++ producer advances read_seq on overflow (plain store read_seq+1) while the GUI ShmRingReader advances read_seq on every pop and drop_all. These plain stores clobber each other: the producer can rewind read_seq from a faster consumer's higher value back to R+1, so already-consumed slots are re-read (duplicate payloads) or the over-full check is computed against a rewound read_seq, corrupting full/empty accounting. Manifests as duplicated/garbled GUI frames and unbounded apparent backlog. -- **fix:** Make read_seq advancement a CAS loop on both the producer overflow path and all consumers, or redesign so the producer never touches read_seq for overflow (advance only write_seq with separate dropped accounting; consumers detect lapping via the per-slot sequence check). At minimum the producer's overflow store must be a compare_exchange so it never moves read_seq backward. - -### #50 [LOW] open_existing() never validates capacity/slot_size against mapped size; slot_header() can compute out-of-bounds offsets (SIGSEGV) -- **subsystem:** ipc_shm -- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:216 (slot math :349-353) -- **impact:** open_existing() only checks st_size >= sizeof(Header), magic, and version; it trusts header->capacity and slot_size_bytes verbatim. slot_header()/slot_payload() then compute sizeof(Header)+index*slot_stride and read slot_size_bytes past it. A stale/truncated/corrupt file (a crash during ftruncate left a short file, or a different-geometry ring with a matching magic) makes the computed slot address point outside the mmap -> SIGSEGV or reading adjacent memory. push()/pop() dereference slot fields with no bounds check -- a hard crash on a headless Pi. -- **fix:** In open_existing() compute expected_size = sizeof(Header) + (sizeof(SlotHeader)+slot_size_bytes)*capacity from header fields and require st_size >= expected_size (and capacity>0, slot_size_bytes>0, no multiplication overflow) before returning, else throw. Apply the same expected-size check in open_or_create()'s EEXIST branch (it currently only compares equality, not actual file size). - -### #51 [MEDIUM] stop_headless_service: a sudo failure aborts the entire GUI launch under set -euo pipefail -- **subsystem:** deploy_daemon -- **location:** /home/europa/Documents/radar_system/start.sh:327 -- **impact:** 'sudo systemctl stop ${SERVICE_NAME}' runs as a bare command under set -euo pipefail. If the sudoers rule is absent/mismatched (SERVICE_USER differs from the login user, install under a different account, or systemctl path moved so the NOPASSWD absolute-path match fails), sudo prompts for a password in a possibly TTY-less context or returns non-zero. Unguarded, a non-zero return makes set -e abort start.sh entirely -- the operator cannot launch the GUI at all, and the still-running daemon keeps owning the radar/SHM/locator port. -- **fix:** Make the stop best-effort and non-fatal: 'sudo -n systemctl stop "${SERVICE_NAME}" || echo WARN...' then verify with systemctl is-active and only hard-fail if still active. Use sudo -n to avoid hanging for a password on a TTY-less invocation. In sudoers allow both /usr/bin/systemctl and /bin/systemctl (or a unit-scoped path) so a relocated binary still matches. - -### #52 [LOW] Locator reader recv()/writer have no SO_RCVTIMEO/SO_SNDTIMEO; a stalled client pins the reader thread and the vlc-update path -- **subsystem:** cpp_processing_locator -- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:63 -- **impact:** read_exact loops on recv() with no timeout. A client that sends a valid 8-byte header advertising a payload then sends nothing leaves reader_loop blocked in recv() indefinitely. The reader is the only path that updates latest_socket_speed_, so a stalled/slow first client can prevent fresh vlc speed updates, and the session is not reaped (exited_ unset) until external teardown. A buggy/hostile LAN client can hold a reader thread per connection. Shutdown still works (request_stop -> shutdown unblocks recv), so this is a steady-state hang. -- **fix:** Set SO_RCVTIMEO on accepted sockets and treat EAGAIN/EWOULDBLOCK in read_exact as a check-stop-flag-and-retry (or slow-client disconnect after N timeouts). Re-check stop_requested_ between recv() calls so a stalled reader notices teardown even before shutdown(). - -### #53 [LOW] Malformed JSON types (array/object where a scalar is expected) raise TypeError, defeating ValueError-only config error handling -- **subsystem:** py_config_models -- **location:** python_app/models/run_config_validation.py:23 -- **impact:** Every coercion in load_switch_payload/load_control_button_payload/load_ring_payload and the radar/laser/locator sections uses bare int()/float()/str() on the raw JSON value. A wrong-type field (radar_port:[1,2], pin:{}, capacity:[..]) raises TypeError, not ValueError. The codec and gui_profile_codec emit clean ValueError for bad shape, so callers/tests that catch ValueError as the canonical bad-config signal let TypeError escape uncaught. The GUI load path catches broad Exception so it survives, but any non-GUI consumer doing 'except ValueError' crashes instead of reporting a config error. -- **fix:** Route all scalar reads through typed helpers (like gui_profile_codec's _optional_int/_optional_float/_optional_string with isinstance checks that raise ValueError), or wrap the int()/float() calls so a non-scalar value raises ValueError with the field name, making the malformed-input contract uniform. - -### #54 [LOW] install-daemon.sh aborts if SUDO_USER is unset (root login / sudo -i / cloud-init), blocking first-boot provisioning -- **subsystem:** deploy_daemon -- **location:** /home/europa/Documents/radar_system/deploy/install-daemon.sh:18 -- **impact:** SERVICE_USER = ${SUDO_USER:-root}; installing from a real root shell, serial console, sudo -i, or cloud-init (SUDO_USER unset) makes SERVICE_USER=root and the script exits demanding a normal login user. On a fresh Pi image, first-boot provisioning is frequently done as root with no SUDO_USER, so the documented one-shot install fails and the daemon is never installed. There is also no validation that SERVICE_USER exists, is in plugdev, or can read the .venv/project tree, so a mismatched user yields a daemon that cannot execute its own venv. -- **fix:** Accept an explicit RADAR_SERVICE_USER arg/env and fall back to the owner of PROJECT_ROOT (stat -c %U) rather than failing when SUDO_USER is empty. Validate the chosen user exists (id), is in plugdev, and owns/can read the .venv and project tree, failing with an actionable message otherwise. - -### #55 [LOW] Socket-supplied vlc speed never triggers reprocessing of the current result and is read non-atomically w.r.t. live config -- **subsystem:** cpp_processing_locator -- **location:** data_acq_and_processing/processing/data_processor/src/data_processor.cpp:166 (reprocess gate :71) -- **impact:** resolve_effective_live_config() overlays latest_socket_speed() onto gpr_speed_m_s every tick, but the reprocess/replay branch is gated solely on the file revision counter, which changes only when the live-config FILE changes. A new vlc value over the socket therefore does not reprocess the current result -- it only affects the next preprocessed frame popped from the ring. If the radar is paused (no new frames), a speed update from the client has no visible effect until motion resumes. Matches an inline comment so may be intended, but the on-wire speed control silently does nothing while idle. -- **fix:** If live speed should affect the current/last result, track a dirty flag when latest_socket_speed() changes value and OR it into the reprocess condition (respecting reprocess_current_result). If the current behavior is intended, document it explicitly so it is not mistaken for a bug during field debugging. - -### #56 [LOW] Watcher pressed/failed slots can execute after the watcher is stopped because signals are never disconnected on teardown -- **subsystem:** py_gui_lifecycle -- **location:** python_app/gui/controllers/app_window_control_button_mixin.py:80 -- **impact:** _stop_control_button_watcher calls watcher.stop() and sets the field None but never disconnects watcher.pressed/failed. A queued cross-thread pressed emitted just before stop() can still be delivered after stop() returns and after the watcher is dereferenced. closeEvent stops the watcher first but then continues multi-second teardown while the loop is not spinning, so in resume-after-close or non-close stop paths a queued press can re-trigger _capture_tmp_reference against an already torn-down pipeline (supervisor stopped, readers None). -- **fix:** In _stop_control_button_watcher, disconnect watcher.pressed/failed from their slots before/after stop() and consider watcher.deleteLater(). Re-check self._supervisor/self._closing state at the top of _on_control_button_pressed. - -### #57 [LOW] parse_combos_from_text raises uncaught/opaque ValueError on non-numeric combo tokens with no count cap -- **subsystem:** py_config_models -- **location:** python_app/models/run_config_validation.py:97 -- **impact:** parse_combos_from_text splits on ',' and ':' then int()s each side with no guard. A token like 'a:0', '0:', or '0:x' raises ValueError('invalid literal for int') exposing the raw failure rather than a combo-context message, and an empty side raises without saying which field is wrong. Reached from the GUI switches text box (caught broadly, so no crash) but the operator gets an opaque Python error. There is also no cap on parsed combos, so a pathological pasted string allocates one ComboModel per token, unbounded before downstream Cartesian expansion. -- **fix:** Wrap the int() conversions in try/except ValueError and re-raise with the offending pair/side (e.g. 'Invalid combo {pair!r}: input/output must be integers'), reject empty sides explicitly, and cap the combo count to a sane maximum to bound resource use. diff --git a/docs/run_config.md b/docs/run_config.md deleted file mode 100644 index 839949f..0000000 --- a/docs/run_config.md +++ /dev/null @@ -1,479 +0,0 @@ -# Run Config Reference - -`run_config.json` is the stable runtime configuration consumed by the GUI, -Python helpers, and C++ pipeline binaries. The active file is normally -`run_config.json`; root-level `*.example.json` files are templates. - -JSON does not support comments. Keep notes in docs, not inside config files. - -## Top-Level Sections - -```json -{ - "radar": {}, - "switches": {}, - "run": {}, - "preprocess": {}, - "gpr": {}, - "rings": {} -} -``` - -## `radar` - -Selects the radar model and sweep settings. - -```json -"radar": { - "model": "compact_m_k209", - "serial": "", - "remote_host": "127.0.0.1", - "remote_port": 50209, - "driver_mode": "native", - "mock_signal_hz": 5000000.0, - "multi_device": {}, - "kamil_adc": {}, - "laser_control": {}, - "sweep": {} -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `model` | `librevna`, `librevna_multi`, `compact_m_k209`, `sn9000`, or `kamil_adc`. | -| `serial` | LibreVNA serial. Empty means first device for single LibreVNA. For `librevna_multi`, this is the master serial. Unused by `compact_m_k209` and `sn9000`. | -| `remote_host` | SCPI server host. For `compact_m_k209` it is the K209 relay server host; for `sn9000` it is the SNVNA HiSLIP host. Ignored by LibreVNA modes. | -| `remote_port` | SCPI server TCP port. Default `50209` for `compact_m_k209` (relay), `4880` for `sn9000` (SNVNA HiSLIP). | -| `driver_mode` | `native` for hardware, `mock` for supported synthetic LibreVNA modes. K209, SN9000, and Kamil ADC require `native`. | -| `mock_signal_hz` | Existing LibreVNA mock signal parameter used by C++ mock acquisition. | -| `multi_device` | Extra settings for `librevna_multi`. | -| `kamil_adc` | External collector process and TTY settings for `kamil_adc`. | -| `laser_control` | Laser board settings applied before `kamil_adc` collection starts. | -| `sweep` | Frequency, point count, IFBW, and power settings. | - -### `radar.sweep` - -```json -"sweep": { - "start_hz": 1000000.0, - "stop_hz": 6000000000.0, - "points": 201, - "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `start_hz` | Sweep start frequency in Hz. | -| `stop_hz` | Sweep stop frequency in Hz. Must be `>= start_hz`. | -| `points` | Number of frequency points. | -| `if_bandwidth_hz` | IF bandwidth in Hz. | -| `stimulus_power_dbm` | Output power in dBm. | - -K209 limits reported by the tested device: - -```text -frequency_hz: 9000 .. 9000000000 -ifbw_hz: 1 .. 300000 -power_dbm: -55 .. +5 -points: 2 .. 500001 -``` - -### `radar.multi_device` - -Used only when `radar.model == "librevna_multi"`. - -```json -"multi_device": { - "slave_serials": [ - "SLAVE_SERIAL_1", - "SLAVE_SERIAL_2" - ], - "force_external_reference": true, - "recovery_attempts": 3 -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `slave_serials` | Exactly two slave LibreVNA serials. | -| `force_external_reference` | Configure the synchronized external reference path. | -| `recovery_attempts` | Reopen/retry attempts after native multi-device acquisition errors. | - -### `radar.kamil_adc` - -Used only when `radar.model == "kamil_adc"`. - -```json -"kamil_adc": { - "project_dir": "/home/europa/Documents/kamil_adc", - "executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture", - "tty_path": "/tmp/ttyADC_data", - "args": [ - "profile:phase", - "clock:internal", - "internal_ref_hz:2000000", - "mode:diff", - "channels:2", - "ch1:2", - "ch2:3", - "do1_toggle_per_frame", - "do1_pair_subtract_avg" - ], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `project_dir` | Working directory for the external ADC collector. Required. | -| `executable_path` | Full path to the Raspberry Pi executable. Required; no filename is assumed. | -| `tty_path` | TTY stream path, for example `/tmp/ttyADC_data`. The producer appends `tty:`. | -| `args` | Explicit collector arguments, excluding any `tty:` argument. | -| `env` | Extra environment variables for the collector process. | -| `startup_timeout_s` | Time allowed for the collector to create a fresh TTY path. | -| `sweep_timeout_s` | Time allowed to receive one full sweep packet. | -| `stop_timeout_s` | Graceful stop timeout before killing the collector process. | - -The TTY frame format is strict: packet start is `0x000A 0xFFFF 0xFFFF 0xFFFF`, -then each sweep point is `0x000A step data1 data2`. Steps must arrive as -`1..N`; `N` is derived from the stream when the next packet start arrives. -`radar.sweep.points` is not used by the Kamil ADC producer. `S21` is -`data1 + j*data2`; `S11` is stored as explicit zeros. - -### `radar.laser_control` - -Used with `kamil_adc` when the laser board must be configured before ADC -collection starts. `laser_control` and `kamil_adc` are one hardware -configuration unit: changing either section requires restarting acquisition. - -```json -"laser_control": { - "enabled": true, - "port": "/dev/ttyUSB0", - "mode": "variation", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, - "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 - }, - "variation": { - "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 28.0, - "static_temp2": 28.9, - "static_current1": 33.0, - "static_current2": 35.0, - "min_value": 33.0, - "max_value": 60.0, - "step": 0.05, - "time_step": 50, - "delay_time": 10 - } -} -``` - -`mode="manual"` uses `manual`. `mode="variation"` uses `variation`. -`variation_type` is the enum name from `laser_control`, for example -`CHANGE_CURRENT_LD1` or `CHANGE_TEMPERATURE_LD2`. - -## `switches` - -Two RF switch sections are used: - -```json -"switches": { - "port1": {}, - "port2": {} -} -``` - -By convention in the C++ pipeline: - -```text -port1 -> output switch -port2 -> input switch -``` - -Switch fields: - -| Field | Meaning | -| --- | --- | -| `name` | Human-readable switch name. | -| `driver_mode` | `native` for GPIO, `mock` to avoid GPIO access. | -| `driver` | `h7992` or `hmc349a`. | -| `radar_port` | Physical radar port mapping, must be unique and either `1` or `2`. | -| `positions` | Number of switch positions. | -| `default_position` | Position selected on open. Zero-based. | -| `gpio_chip` | Linux GPIO chip path, usually `/dev/gpiochip0`. | -| `pin_a` | First GPIO control pin. | -| `pin_b` | Second GPIO control pin for `h7992`. | -| `invert_logic` | Logic inversion for supported switch drivers. | - -Use mock switches on a laptop without GPIO: - -```json -"driver_mode": "mock" -``` - -## `control_button` - -Optional physical GPIO push-button that triggers a runtime action on press. -The watcher runs in both GUI and headless modes (it is attached to the main -window, which both launch paths build). On press it reuses the existing -"Capture Tmp Reference" flow: stop the pipeline, capture a fresh tmp S21 -reference with the current sweep settings, then restart the pipeline if it had -been running. - -```json -"control_button": { - "enabled": true, - "gpio_chip": "/dev/gpiochip0", - "pin": 26, - "active_low": true, - "bias": "", - "debounce_ms": 50, - "action": "capture_tmp_reference" -} -``` - -| Field | Meaning | -| --- | --- | -| `enabled` | Master switch. When `false` (default) no GPIO line is opened, so non-Pi hosts are unaffected. | -| `gpio_chip` | Linux GPIO chip path, usually `/dev/gpiochip0`. | -| `pin` | BCM line offset of the button. `26` is physical pin 37, with GND on physical pin 39. | -| `active_low` | `true` for a button wired to GND with the internal pull-up: the line idles high and a press is detected on the falling edge. `false` mirrors this for a button wired to 3V3 with a pull-down (rising edge). | -| `bias` | Internal bias override: `pull_up`, `pull_down`, or `disabled`. Empty (default) derives the bias from `active_low`. | -| `debounce_ms` | Hardware debounce period applied by the kernel, in milliseconds. | -| `action` | Action to run on press. Currently only `capture_tmp_reference`. | - -Occupied BCM lines (native switches) are `17`, `22`, `23`, `27`; pick a free -line such as `16`, `20`, `21`, or `26` for the button. A failure to open the -line (missing chip, line already in use) is logged as a warning and never -aborts startup. - -## `run` - -Runtime behavior and combo selection. - -```json -"run": { - "settling_ms": 0, - "idle_sleep_ms": 2, - "continuous": true, - "processing_live_config_path": "python_app/runtime/processing_live.json", - "locator_server": {}, - "combos": [ - {"input": 0, "output": 0} - ] -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `settling_ms` | Delay after switching before measuring. | -| `idle_sleep_ms` | Sleep between continuous collections. | -| `continuous` | `true` loops until stopped; `false` captures one collection and exits. | -| `processing_live_config_path` | Runtime path used by processing live settings. | -| `locator_server` | Embedded TCP server settings for publishing locator results. | -| `combos` | Zero-based switch combinations to acquire. | - -`combos` entries use input/output switch positions: - -```json -{"input": 2, "output": 1} -``` - -For `librevna_multi` and `sn9000`, the model constraints force the canonical -virtual matrix: - -```text -input: 0..3 -output: 0..1 -``` - -## `run.locator_server` - -Settings for the embedded locator result TCP server. - -| Field | Meaning | -| --- | --- | -| `device_id` | Device identifier in locator payloads. | -| `protocol_version` | Locator payload protocol version. | -| `host` | Bind host, commonly `0.0.0.0`. | -| `port` | TCP port, commonly `8888`. | -| `max_payload_bytes` | Maximum result payload size. | -| `client_queue_size` | Per-client queue size. | -| `logger_name` | Logger name used by the service. | - -## `preprocess` - -Names or bundle paths for calibration/reference assets used by preprocessing. - -```json -"preprocess": { - "s21": { - "calibration": {"set_name": "", "bundle_path": ""}, - "reference": {"set_name": "", "bundle_path": ""} - }, - "s11": { - "calibration": { - "open": {"set_name": "", "bundle_path": ""}, - "short": {"set_name": "", "bundle_path": ""}, - "load": {"set_name": "", "bundle_path": ""} - }, - "reference": {"set_name": "", "bundle_path": ""} - }, - "notch": { - "enabled": true, - "bands_hz": [], - "taper_width_hz": 40000000.0, - "taper_type": "cosine" - } -} -``` - -`set_name` selects a stored set for the active radar key. `bundle_path` can -point to an exported bundle. Empty values mean no asset is selected. - -`notch.bands_hz` is a list of `[low_hz, high_hz]` ranges. `taper_type` is -`cosine` or `hard`. - -## `gpr` - -GPR geometry and processing configuration. - -```json -"gpr": { - "relative_permittivity": 1.0, - "tx_geometry": [ - {"output_pos": 0, "x_m": 0.905} - ], - "rx_geometry": [ - {"input_pos": 0, "x_m": -0.18} - ] -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `relative_permittivity` | Medium relative permittivity used for propagation speed. | -| `tx_geometry` | Transmitter positions keyed by output switch position. | -| `rx_geometry` | Receiver positions keyed by input switch position. | - -Geometry positions must match configured switch positions. For example, an -`output_pos` of `1` requires the output switch to have at least 2 positions. - -## `rings` - -Shared-memory ring endpoints used by native processes. - -```json -"rings": { - "raw": {"name": "/radar_raw", "capacity": 50, "slot_size_bytes": 2097152}, - "raw_tap": {"name": "/radar_raw_tap", "capacity": 50, "slot_size_bytes": 2097152}, - "preprocessed": {"name": "/radar_preprocessed", "capacity": 50, "slot_size_bytes": 2097152}, - "preprocessed_tap": {"name": "/radar_preprocessed_tap", "capacity": 50, "slot_size_bytes": 2097152}, - "results": {"name": "/radar_results", "capacity": 50, "slot_size_bytes": 2097152} -} -``` - -Fields: - -| Field | Meaning | -| --- | --- | -| `name` | POSIX shared-memory object name. | -| `capacity` | Number of slots. | -| `slot_size_bytes` | Maximum serialized payload size per slot. | - -Use unique ring names for parallel tests to avoid collisions with a running GUI -session. - -## Minimal Model Examples - -Single LibreVNA: - -```json -"radar": { - "model": "librevna", - "serial": "", - "driver_mode": "native" -} -``` - -Multi-device LibreVNA: - -```json -"radar": { - "model": "librevna_multi", - "serial": "MASTER_SERIAL", - "driver_mode": "native", - "multi_device": { - "slave_serials": ["SLAVE_1", "SLAVE_2"], - "force_external_reference": true, - "recovery_attempts": 3 - } -} -``` - -Compact-M K209 via remote server: - -```json -"radar": { - "model": "compact_m_k209", - "remote_host": "192.168.1.10", - "remote_port": 50209, - "driver_mode": "native" -} -``` - -SN9000 (PLANAR Иридиум) via SNVNA HiSLIP: - -```json -"radar": { - "model": "sn9000", - "remote_host": "192.168.1.10", - "remote_port": 4880, - "driver_mode": "native" -} -``` - -Kamil ADC: - -```json -"radar": { - "model": "kamil_adc", - "serial": "kamil_adc", - "driver_mode": "native", - "kamil_adc": { - "project_dir": "/home/europa/Documents/kamil_adc", - "executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture", - "tty_path": "/tmp/ttyADC_data" - }, - "laser_control": { - "enabled": true, - "port": "/dev/ttyUSB0", - "mode": "variation" - } -} -``` diff --git a/docs/sn9000_setup.md b/docs/sn9000_setup.md deleted file mode 100644 index 9a7407c..0000000 --- a/docs/sn9000_setup.md +++ /dev/null @@ -1,140 +0,0 @@ -# SN9000 (PLANAR SNVNA / Иридиум) Setup - -This project controls the PLANAR SN9000 multi-port VNA through the SNVNA -companion application running on an external PC. The production path is: - -```text -SN9000 --USB 2.0--> SNVNA host PC --HiSLIP/VISA--> radar_system -``` - -For complete run-mode instructions see -[`docs/operation_modes.md`](operation_modes.md). For `run_config.json` field -reference see [`docs/run_config.md`](run_config.md). - -The SN9000 hardware has no built-in SCPI server; the SNVNA application on the -companion PC exposes the SCPI HiSLIP server (default port `4880`). This -project uses HiSLIP only, with the same `pyvisa` + IVI VISA stack already -required by K209 — there is no additional dependency. - -For maximum throughput the driver: - -- Uses HiSLIP, not raw TCP Socket. -- Keeps one persistent VISA session. -- Sends `FORM:DATA REAL32` and `FORM:BORD SWAP` (little-endian) once. -- Pre-configures 10 traces covering all S-parameters of the 2×4 matrix so - one trigger drives both stimulus ports. -- Sends the entire acquisition as one synchronized SCPI message: - `TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S31;…;:SENS:DATA:CORR? S62`. - The K209 setup notes the same constraint: splitting `TRIG:SING` from the - data queries can return `-211,"Trigger system is not in the trigger wait state"`. - -Topology (manual p. 1457): - -| Trace | Output position | Stimulus port | Input position | Receiver port | -|-------|-----------------|---------------|----------------|----------------| -| S11 | 0 | 1 | (reflection) | 1 | -| S31 | 0 | 1 | 0 | 3 | -| S41 | 0 | 1 | 1 | 4 | -| S51 | 0 | 1 | 2 | 5 | -| S61 | 0 | 1 | 3 | 6 | -| S22 | 1 | 2 | (reflection) | 2 | -| S32 | 1 | 2 | 0 | 3 | -| S42 | 1 | 2 | 1 | 4 | -| S52 | 1 | 2 | 2 | 5 | -| S62 | 1 | 2 | 3 | 6 | - -## Required Components - -Install these on the machine that runs the SN9000 smoke test or acquisition -process: - -1. SNVNA companion application from Planar. - - The SN9000 hardware is connected to this host over USB 2.0. - -2. IVI VISA runtime and development files. - - Must support TCPIP HiSLIP resources. - - Suitable implementations include NI-VISA or Keysight IO Libraries Suite. - - If K209 already works on this host, no additional install is needed. - -3. Project Python environment. - - Use the repository virtual environment, not system Python. - - Install `requirements.txt` into `.venv`. - -## SNVNA HiSLIP Server - -Start SNVNA with the SN9000 connected over USB 2.0. Enable HiSLIP server on -port `4880`. From the SNVNA UI: - -```text -System -> Settings -> Remote control network settings -> HiSLIP server -> On -System -> Settings -> Remote control network settings -> HiSLIP port -> 4880 -``` - -Verify that the server is listening: - -```bash -ss -ltnp | grep 4880 -``` - -If SNVNA runs on a different machine from `radar_system`, set -`radar.remote_host` to that machine's IP address. - -The VISA resource string the driver assembles is: - -```text -TCPIP0::::hislip0,::INSTR -``` - -## `run_config.json` - -```json -"radar": { - "model": "sn9000", - "remote_host": "127.0.0.1", - "remote_port": 4880, - "driver_mode": "native" -} -``` - -The 2×4 virtual switch matrix is enforced automatically; do not edit -`switches.port1` / `switches.port2` or `run.combos` for SN9000 mode — the -config codec rewrites them on load. - -## Python Smoke Test - -Use the project virtual environment: - -```bash -.venv/Scripts/python.exe -m python_app.scripts.sn9000_smoke_test ^ - --host 127.0.0.1 --port 4880 ^ - --start-hz 1000000 --stop-hz 3000000000 ^ - --points 201 --ifbw-hz 10000 --power-dbm -10 ^ - --no-preset -``` - -Expected result: - -```text -SN9000 IDN: Planar, SN9000-N, ... -SN9000 collection OK: traces=8, points=201, first_hz=..., last_hz=..., mean_abs_s21=... -``` - -Use `--no-preset` for the first smoke test to avoid resetting the current -SNVNA session. Remove it when testing the full driver setup path. - -## SN9000 Limits - -The SNVNA SCPI surface exposes service capability queries identical to K209: - -```text -SERV:SWE:FREQ:MAX? Upper frequency bound in Hz. -SERV:SWE:FREQ:MIN? Lower frequency bound in Hz. -SERV:SWE:POIN? Maximum sweep point count. -SERV:SWE:POW:MAX? Upper power bound in dBm. -SERV:SWE:POW:MIN? Lower power bound in dBm. -``` - -The base SN9000 model covers `0.3 MHz .. 9 GHz`; power range is -`-45 .. +10 dBm` up to 6 GHz, and `-45 .. +2 dBm` from 6 GHz to 9 GHz -(manual p. 58). IF bandwidth selectable in the 1, 1.5, 2, 3, 5, 7 sequence -across decades from `1 Hz` to `300 kHz` (manual p. 58, 1261). diff --git a/Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf b/docs/Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf similarity index 100% rename from Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf rename to docs/Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index e3bf46a..1f4a490 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -29,6 +29,7 @@ from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin +from python_app.gui.controllers.app_window_web_mixin import AppWindowWebMixin from python_app.gui.preprocess_dialog import PreprocessDialog from python_app.models.dataset_model import ResultCollection, SweepCollection from python_app.models.gui_profile_model import GuiProfileModel @@ -53,6 +54,7 @@ class AppWindow( AppWindowPipelineMixin, AppWindowSnapshotMixin, AppWindowControlButtonMixin, + AppWindowWebMixin, QMainWindow, ): """Top-level window coordinating GUI state and acquisition runtime.""" @@ -288,6 +290,7 @@ class AppWindow( self._timer.start() self._maybe_auto_start_pipeline() self._start_control_button_watcher() + self._init_web_ui() if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"): self._install_headless_watchdog() @@ -629,6 +632,8 @@ class AppWindow( return self._closing = True try: + # 0) Stop the web server first so a late request cannot start work. + self._shutdown_web_ui() # 0) Stop the GPIO button watcher so a late press cannot start work. self._stop_control_button_watcher() self._resume_pipeline_after_capture = False 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 71d1e34..4c26a9b 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 @@ -3,10 +3,125 @@ from __future__ import annotations from PyQt6.QtCore import QSignalBlocker +from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox from python_app.gui.runtime.constraints import validate_processing_mode_constraints from python_app.orchestration.live_processing_config import ProcessingLiveConfig +_GPR_MODES = ("gpr", "legacy_gpr") + + +def _is_legacy_gpr(window) -> bool: + return window._processing_mode.currentText() == "legacy_gpr" + + +def _attr(name: str): + """Widget getter for a field backed by a single fixed widget.""" + return lambda window: getattr(window, name) + + +def _dual(gpr_attr: str, legacy_attr: str): + """Widget getter for a gpr_* field with separate gpr / legacy_gpr widgets.""" + return lambda window: getattr(window, legacy_attr if _is_legacy_gpr(window) else gpr_attr) + + +# The ONE authoritative field <-> widget map (inverse of `_live_processing_config`), +# plus each field's display group and the processor mode(s) it applies to. Everything +# else the web form needs — input type, combobox options, numeric ranges, the current +# value, the enabled state — is read live FROM these widgets by `_build_web_settings_schema`, +# so the web hardcodes none of it and always mirrors the desktop. `modes=None` => all modes. +# Entries: (field, group, modes, widget_getter). +_WEB_LIVE_SCHEMA = [ + ("processor_mode", "Processor", None, _attr("_processing_mode")), + ("pass_through_fixed_y_enabled", "Pass-through", ("pass_through",), _attr("_pass_through_fixed_y_enabled")), + ("pass_through_y_min_db", "Pass-through", ("pass_through",), _attr("_pass_through_y_min_db")), + ("pass_through_y_max_db", "Pass-through", ("pass_through",), _attr("_pass_through_y_max_db")), + ("bscan_axis", "B-scan", ("bscan",), _attr("_bscan_axis")), + ("bscan_cut_m", "B-scan", ("bscan",), _attr("_bscan_cut_m")), + ("bscan_max_depth_m", "B-scan", ("bscan",), _attr("_bscan_max_depth_m")), + ("bscan_gain", "B-scan", ("bscan",), _attr("_bscan_gain")), + ("bscan_start_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_start_freq_mhz")), + ("bscan_stop_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_stop_freq_mhz")), + ("legacy_gpr_mode", "Mode", ("legacy_gpr",), _attr("_legacy_gpr_config_mode")), + ("gpr_input_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_input_positions_input", "_legacy_gpr_input_positions_input")), + ("gpr_output_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_output_positions_input", "_legacy_gpr_output_positions_input")), + ("gpr_min_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_min_depth_m", "_legacy_gpr_min_depth_m")), + ("gpr_max_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_max_depth_m", "_legacy_gpr_max_depth_m")), + ("gpr_start_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_start_freq_mhz", "_legacy_gpr_start_freq_mhz")), + ("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")), + ("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")), + ("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")), + ("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")), + ("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")), + ("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")), + ("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")), + ("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")), + ("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")), + ("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")), + ("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")), + ("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")), + ("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")), + ("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")), + ("legacy_gpr_min_visible_pair_count", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_min_visible_pair_count")), + ("gpr_look_angle_deg", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_look_angle_deg")), + ("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_apply_freq_phase_correction")), + ("gpr_reference_mode", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_reference_mode")), + ("ignore_socket_speed", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_ignore_socket_speed_enabled")), + ("gpr_speed_m_s", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_speed_m_s")), +] +_WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA} + + +def _web_field_schema(field: str, group: str, widget) -> dict | None: + """Describe one widget for the web form (type, options/range, value, enabled).""" + base = {"name": field, "group": group, "enabled": bool(widget.isEnabled())} + if isinstance(widget, QComboBox): + return {**base, "kind": "select", "value": widget.currentText(), + "options": [widget.itemText(i) for i in range(widget.count())]} + if isinstance(widget, QCheckBox): + return {**base, "kind": "bool", "value": bool(widget.isChecked())} + if isinstance(widget, QSpinBox): + return {**base, "kind": "int", "value": int(widget.value()), + "min": int(widget.minimum()), "max": int(widget.maximum()), "step": int(widget.singleStep())} + if isinstance(widget, QDoubleSpinBox): + return {**base, "kind": "float", "value": float(widget.value()), + "min": float(widget.minimum()), "max": float(widget.maximum()), + "step": float(widget.singleStep()), "decimals": int(widget.decimals())} + if isinstance(widget, QLineEdit): + return {**base, "kind": "text", "value": widget.text()} + return None + + +def _build_web_settings_schema(window) -> list[dict]: + """Build the settings schema for the active mode straight from the Qt widgets.""" + mode = window._processing_mode.currentText() + schema: list[dict] = [] + for field, group, modes, getter in _WEB_LIVE_SCHEMA: + if modes is not None and mode not in modes: + continue + entry = _web_field_schema(field, group, getter(window)) + if entry is not None: + schema.append(entry) + return schema + + +def _set_web_live_field(window, field: str, value) -> None: + """Write one web value into the desktop widget that feeds ``field``.""" + getter = _WEB_LIVE_GETTERS.get(field) + if getter is None: + return + widget = getter(window) + if isinstance(widget, QComboBox): + window._set_combo_current_text(widget, str(value)) + elif isinstance(widget, QCheckBox): + widget.setChecked(bool(value)) + elif isinstance(widget, QSpinBox): + widget.setValue(int(float(value))) + elif isinstance(widget, QDoubleSpinBox): + widget.setValue(float(value)) + elif isinstance(widget, QLineEdit): + widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value)) + class AppWindowLiveProcessingMixin: """Handle live processing updates, redraws, and locator republishing.""" @@ -55,7 +170,8 @@ class AppWindowLiveProcessingMixin: y_min_db = float(self._pass_through_y_min_db.value()) y_max_db = float(self._pass_through_y_max_db.value()) - return ProcessingLiveConfig( + visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds() + config = ProcessingLiveConfig( processor_mode=mode, pass_through_channel="s21", pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()), @@ -95,11 +211,16 @@ class AppWindowLiveProcessingMixin: gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()), gpr_min_visible_score=float(self._gpr_min_visible_score.value()), legacy_gpr_min_visible_pair_count=float(self._legacy_gpr_min_visible_pair_count.value()), + gpr_visible_x_min_m=visible_x_min, + gpr_visible_x_max_m=visible_x_max, + gpr_visible_z_min_m=visible_z_min, + gpr_visible_z_max_m=visible_z_max, ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()), reprocess_current_result=bool(reprocess_current_result), history_command_seq=int(self._history_command_seq), history_command=str(history_command), ) + return config def _write_live_processing_config( self, @@ -118,8 +239,50 @@ class AppWindowLiveProcessingMixin: ) ) + def _apply_web_live_settings(self, fields: dict) -> None: + """Apply web-requested live settings through the SAME path as a desktop edit. + + Writes the values into the desktop widgets, then runs the single + ``_on_processing_live_settings_changed`` handler — one writer, one redraw — + so the browser and the desktop never desync and ``processing_live.json`` has + exactly one author. The per-widget change signals are neutralized by a + suppression flag so the final handler runs once instead of dozens of times; + a history command keeps its own server-managed sequence-bump path. + """ + try: + history_command = str(fields.get("history_command", "none")) + settings = { + name: value + for name, value in fields.items() + if name not in {"history_command", "history_command_seq"} + } + self._suppress_live_settings_handler = True + try: + # processor_mode first: dual-sourced gpr_* fields route to the gpr or + # legacy_gpr widget based on the active mode. + if "processor_mode" in settings: + _set_web_live_field(self, "processor_mode", settings.pop("processor_mode")) + for name, value in settings.items(): + _set_web_live_field(self, name, value) + finally: + self._suppress_live_settings_handler = False + + if history_command in {"clear_all", "remove_last"}: + self._write_live_processing_config(history_command=history_command, bump_history_seq=True) + self._on_processing_live_settings_changed() + except Exception as exc: # noqa: BLE001 + self._show_exception("Failed to apply web live settings", exc) + + def _web_settings_schema(self) -> list[dict]: + """Return the live-settings schema for the active mode (read from widgets).""" + return _build_web_settings_schema(self) + def _on_processing_live_settings_changed(self, *_args) -> None: """Handle live-processing setting changes and trigger redraw when needed.""" + # Web-apply sets many widgets at once; their individual change signals are + # suppressed so this writer+redraw runs exactly once at the end. + if getattr(self, "_suppress_live_settings_handler", False): + return try: current_mode = self._processing_mode.currentText() if self._is_gpr_processing_mode(current_mode): diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 9f034b4..2128014 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -22,6 +22,7 @@ from python_app.orchestration.preprocess_assets import ( preprocess_asset_model, runtime_preprocess_asset_keys, ) +from python_app.orchestration.restart_policy import RestartPolicy from python_app.orchestration.shm_reader import ShmRingReader @@ -36,6 +37,15 @@ class AppWindowPipelineMixin: # a wedged reader cannot stay silently broken forever. _READER_ERROR_RECONNECT_AT = 40 _READER_ERROR_STOP_AT = 400 + # If a pipeline child exits unexpectedly while the run should be live, relaunch + # the whole pipeline from the last-written runtime config. It retries FOREVER with + # capped back-off (this is an unattended appliance — it must keep trying to come + # back, never permanently stop); the failure streak resets once a healthy poll sees + # data. The "if it breaks it comes back up" contract holds in BOTH GUI and headless. + _RESTART_POLICY = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0) + # Safety backstop so a wedged/dropping processor cannot leave a single capture + # polling forever with no completion and no error. Generous, not a tight deadline. + _SINGLE_CAPTURE_TIMEOUT_S = 300.0 def _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool: """Return whether alive `data_processor` was started with different stable run settings.""" @@ -148,6 +158,12 @@ class AppWindowPipelineMixin: self._drop_pending_ring_payloads(include_results=True) self._last_reader_error_signature = None self._reader_error_repeat_count = 0 + # Record what to relaunch if a child later dies unexpectedly. Only a + # continuous run auto-restarts; a single capture is bounded by its deadline. + self._active_run_config = config + self._active_run_config_path = config_path + self._pipeline_should_run = not single_capture + self._pipeline_restart_count = 0 if single_capture: self._single_capture_start_ns = time.monotonic_ns() @@ -246,6 +262,7 @@ class AppWindowPipelineMixin: def _stop_run(self) -> None: """Stop acquisition-side processes and close readers as needed.""" + self._pipeline_should_run = False # an explicit stop disables crash auto-restart was_running = self._supervisor.is_running() if was_running: self._supervisor.stop_orchestrator() @@ -271,6 +288,7 @@ class AppWindowPipelineMixin: def _stop_all_processes(self) -> None: """Stop all managed pipeline processes and close all readers.""" + self._pipeline_should_run = False # an explicit stop disables crash auto-restart was_running = self._supervisor.is_running() or self._supervisor.is_processor_running() self._supervisor.stop_all() self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02) @@ -299,12 +317,8 @@ class AppWindowPipelineMixin: def _poll_rings(self) -> None: """Poll readers, ingest history, and trigger rendering.""" - for report in self._supervisor.collect_exit_reports(): - if report.level == "INFO": - self._log(report.format()) - continue - self._status_label.setText("Status: error") - self._log_error(report.format()) + self._web_update_snapshot() # guarded internally; never raises + self._handle_process_exit_reports() try: if self._raw_reader is not None: @@ -318,9 +332,13 @@ class AppWindowPipelineMixin: if self._single_capture_active: if self._finish_single_capture_if_ready(): return + self._check_single_capture_deadline() return if result_latest is not None: + # Genuine data flowed: the pipeline is healthy, so reset the + # crash-storm budget (it only caps consecutive crash-restarts). + self._pipeline_restart_count = 0 render_started_ns = time.monotonic_ns() self._draw_preferred_collection(result_latest=result_latest) self._pipeline_metrics.record( @@ -331,6 +349,88 @@ class AppWindowPipelineMixin: except Exception as exc: # noqa: BLE001 self._handle_reader_poll_error(exc) + def _handle_process_exit_reports(self) -> None: + """Log child exits and auto-restart the pipeline on an unexpected death. + + Runs first in the poll tick and is fully guarded: it must never raise, or + it would abort the Qt slot. An unexpected (non-clean) exit while the run is + meant to be live triggers a bounded relaunch — in both GUI and headless. + """ + unexpected = False + try: + for report in self._supervisor.collect_exit_reports(): + if report.level == "INFO": + self._log(report.format()) + continue + self._status_label.setText("Status: error") + self._log_error(report.format()) + unexpected = True + except Exception as exc: # noqa: BLE001 - the poll tick must survive this + self._log_exception("Failed to collect process exit reports", exc, level="ERROR") + return + + if unexpected and getattr(self, "_pipeline_should_run", False): + self._recover_pipeline_after_crash() + + def _recover_pipeline_after_crash(self) -> None: + """Relaunch the pipeline after an unexpected child exit (GUI and headless). + + Re-spawns from the already-written runtime config — no widgets, no dialogs, + no re-validation — so it is safe to call from the poll tick. Retries forever + with capped back-off (never gives up); a healthy poll resets the failure streak. + """ + now = time.monotonic() + consecutive_failures = getattr(self, "_pipeline_restart_count", 0) + if not self._RESTART_POLICY.should_restart_now( + now_s=now, + last_restart_s=getattr(self, "_last_pipeline_restart_s", 0.0), + consecutive_failures=consecutive_failures, + ): + return # still inside the current back-off window; let it settle + self._last_pipeline_restart_s = now + self._pipeline_restart_count = consecutive_failures + 1 + + config = getattr(self, "_active_run_config", None) + config_path = getattr(self, "_active_run_config_path", None) + if config is None or config_path is None: + self._pipeline_should_run = False + self._log_error("Cannot auto-restart pipeline: no active run configuration recorded.") + return + + self._log_error( + "Pipeline process exited unexpectedly; restarting " + f"(attempt {self._pipeline_restart_count}, next back-off " + f"{self._RESTART_POLICY.backoff_for(self._pipeline_restart_count):.0f}s)." + ) + try: + # Note: do NOT drain rings here — draining pumps the Qt event loop, which + # would re-enter _poll_rings mid-restart (and reset the crash-storm count). + self._supervisor.stop_all() + self._close_readers(keep_results=False) + self._supervisor.start(config_path, allow_clean_orchestrator_exit=False) + self._raw_reader = ShmRingReader(config.rings.raw_tap.name) + self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name) + self._result_reader = ShmRingReader(config.rings.results.name) + self._processor_run_signature = self._build_processor_run_signature(config) + self._drop_pending_ring_payloads(include_results=True) + self._status_label.setText("Status: running") + self._log("Pipeline auto-restarted after crash.") + except Exception as exc: # noqa: BLE001 - retry on the next crash signal + self._log_exception("Pipeline auto-restart failed; will retry", exc, level="ERROR") + + def _check_single_capture_deadline(self) -> None: + """Fail a single capture that never completes so it cannot hang forever.""" + start = self._single_capture_start_ns + if start is None: + return + if time.monotonic_ns() - start <= int(self._SINGLE_CAPTURE_TIMEOUT_S * 1e9): + return + self._log_error( + f"Single capture timed out after {self._SINGLE_CAPTURE_TIMEOUT_S:.0f}s " + "with no result; stopping." + ) + self._stop_run() + def _handle_reader_poll_error(self, exc: Exception) -> None: """Surface a reader-poll failure without spamming the log. diff --git a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py index 2c448f8..4115c1b 100644 --- a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py @@ -8,8 +8,10 @@ import pyqtgraph as pg from python_app.models.dataset_model import ResultCollection from python_app.orchestration.gpr_locator import ( + apply_object_draw_limits as gpr_apply_object_draw_limits, collection_payload_by_name as gpr_collection_payload_by_name, collection_payloads_by_prefix as gpr_collection_payloads_by_prefix, + filter_object_rows as gpr_filter_object_rows, gpr_object_rows as extract_gpr_object_rows, ) @@ -252,13 +254,12 @@ class AppWindowGprPlotMixin: self._draw_gpr_geometry_markers() + # Both gpr and legacy_gpr filter heatmap object markers by their own + # threshold + visible X/Z window (legacy has the same controls), so the + # markers match the objects-only view instead of showing raw detections. points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4) if points_payload is not None and np.asarray(points_payload.table).size > 0: - points = ( - self._filtered_gpr_object_rows(collection) - if self._processing_mode.currentText() == "gpr" - else np.asarray(points_payload.table, dtype=np.float32) - ) + points = self._filtered_gpr_object_rows(collection) else: points = np.zeros((0, 3), dtype=np.float32) @@ -388,13 +389,7 @@ class AppWindowGprPlotMixin: @staticmethod def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray: """Apply object count/top-M drawing rules to already-filtered rows.""" - if limits is None or rows.size == 0: - return rows - - max_detected_objects, draw_top_objects = limits - if rows.shape[0] > int(max_detected_objects): - return np.zeros((0, rows.shape[1]), dtype=rows.dtype) - return rows[: max(0, int(draw_top_objects))] + return gpr_apply_object_draw_limits(rows, limits) @staticmethod def _gpr_display_y_min(z_min: float, z_max: float) -> float: @@ -517,17 +512,13 @@ class AppWindowGprPlotMixin: return rows x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds() - min_score = self._gpr_locator_threshold() - finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1) - visible_mask = ( - finite_mask - & (rows[:, 2] >= min_score) - & (rows[:, 0] >= x_min) - & (rows[:, 0] <= x_max) - & (rows[:, 1] >= z_min) - & (rows[:, 1] <= z_max) + return gpr_filter_object_rows( + rows, + min_score=self._gpr_locator_threshold(), + x_bounds=(x_min, x_max), + z_bounds=(z_min, z_max), + draw_limits=self._gpr_draw_limits(), ) - return self._apply_object_draw_limits(rows[visible_mask], self._gpr_draw_limits()) def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool: """Draw only detected GPR objects inside configured X/Z bounds.""" diff --git a/python_app/gui/controllers/app_window_web_mixin.py b/python_app/gui/controllers/app_window_web_mixin.py new file mode 100644 index 0000000..f209436 --- /dev/null +++ b/python_app/gui/controllers/app_window_web_mixin.py @@ -0,0 +1,213 @@ +"""Embed the browser UI inside the AppWindow without duplicating any rendering. + +The desktop already owns the hardware and draws every plot with pyqtgraph, so the +web view simply streams a snapshot of the *current Qt plot widget* — the browser +shows exactly what the desktop shows, for every processing mode, with zero +re-implemented rendering. Controls cross back to the Qt main thread through queued +signals (the GPIO-button pattern) and invoke the AppWindow's existing buttons. + +Two roles, kept separate from the Qt-free :mod:`python_app.webui` package: + +* :class:`AppWindowWebController` — the Qt bridge implementing the web contract. +* :class:`AppWindowWebMixin` — wiring that grabs the plot, refreshes snapshots, + and starts/stops the server. +""" + +from __future__ import annotations + +import base64 +import contextlib +import dataclasses +import os +import time + +from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal + +from python_app.orchestration.live_processing_config import ProcessingLiveConfig + +_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT" +_DEFAULT_PORT = 8080 +# Headless has no shown window, so give the offscreen window a usable size for the +# grabbed plot. In GUI mode the user's real (shown) window size is used as-is. +_HEADLESS_PLOT_SIZE = (1600, 900) +# Grab/encode the plot at most this often (the plot only changes per result/redraw). +_GRAB_INTERVAL_S = 0.2 +_LIVE_FIELD_NAMES = frozenset(field.name for field in dataclasses.fields(ProcessingLiveConfig)) + + +class AppWindowWebController(QObject): + """Qt bridge satisfying the web contract: streams the plot, forwards controls. + + Mutating calls (made on the web thread) emit queued signals the AppWindow + connects to its existing slots. Read calls return immutable snapshots the + AppWindow refreshes on its poll tick — replaced atomically, never mutated in + place, so the web thread reads a consistent value without locking. + """ + + start_requested = pyqtSignal() + stop_requested = pyqtSignal() + single_capture_requested = pyqtSignal() + capture_requested = pyqtSignal() + apply_settings_requested = pyqtSignal(dict) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._status: dict = {} + self._live_settings: list = [] + self._frame: dict | None = None + + # -- Snapshot refresh (Qt main thread) ----------------------------------- + + def update_snapshot(self, *, status: dict, live_settings: dict, frame: dict | None) -> None: + """Replace the served snapshots; ``frame`` only when a new one was grabbed.""" + self._status = status + self._live_settings = live_settings + if frame is not None: + self._frame = frame + + # -- WebController reads (web thread) ------------------------------------ + + def status(self) -> dict: + return dict(self._status) + + def current_live_settings(self) -> list: + return list(self._live_settings) + + def peek_frame(self) -> dict | None: + """Return the most recent rendered-plot frame (or None before the first).""" + return self._frame + + # -- WebController controls (web thread -> Qt main thread) --------------- + + def start(self) -> None: + self.start_requested.emit() + + def stop(self) -> None: + self.stop_requested.emit() + + def single_capture(self) -> None: + self.single_capture_requested.emit() + + def capture_tmp_reference(self) -> None: + self.capture_requested.emit() + + def apply_live_settings(self, fields: dict) -> dict: + unknown = set(fields) - _LIVE_FIELD_NAMES + if unknown: + raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}") + self.apply_settings_requested.emit(dict(fields)) + return self.current_live_settings() + + +class AppWindowWebMixin: + """Start/stop the embedded web server and feed it the live plot + settings.""" + + def _init_web_ui(self) -> None: + """Start the web server (default-on, both modes), wired to existing buttons.""" + self._web_controller: AppWindowWebController | None = None + self._web_server = None + self._web_frame_seq = 0 + self._web_last_png: str | None = None + self._web_last_grab_s = 0.0 + + try: + from python_app.webui.server import WebUiServer + + # Headless never shows the window; size it so the grabbed plot is usable. + if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"): + self.resize(*_HEADLESS_PLOT_SIZE) + + controller = AppWindowWebController(parent=self) + controller.start_requested.connect(self._start_run) + controller.stop_requested.connect(self._stop_run) + controller.single_capture_requested.connect(self._start_single_capture) + controller.capture_requested.connect(self._capture_tmp_reference) + controller.apply_settings_requested.connect(self._apply_web_live_settings) + + self._web_controller = controller + self._web_update_snapshot() # seed snapshots before the first request + + port = self._web_ui_port() + self._web_server = WebUiServer(controller, port=port) + self._web_server.start() + self._log(f"Web UI started on http://0.0.0.0:{port}") + except Exception as exc: # noqa: BLE001 - a missing dependency must not abort startup + self._log_exception("Failed to start web UI", exc, level="WARN") + self._web_controller = None + self._web_server = None + + def _web_update_snapshot(self) -> None: + """Refresh the snapshots the bridge serves (called on the Qt poll tick). + + Must NEVER raise: it runs inside the periodic render tick, where an escaping + exception would abort the Qt slot (qFatal) and kill the app. + """ + controller = getattr(self, "_web_controller", None) + if controller is None: + return + try: + controller.update_snapshot( + status={ + "running": self._supervisor.is_running(), + "processor_running": self._supervisor.is_processor_running(), + "ring_name": self._defaults_config.rings.results.name, + }, + live_settings=self._web_settings_schema(), + frame=self._web_grab_frame_if_due(), + ) + except Exception as exc: # noqa: BLE001 - the render loop must survive this + self._web_snapshot_errors = getattr(self, "_web_snapshot_errors", 0) + 1 + if self._web_snapshot_errors % 200 == 1: + self._log_exception("Web UI snapshot refresh failed", exc, level="WARN") + + def _web_grab_frame_if_due(self) -> dict | None: + """Grab the current plot as a PNG frame, throttled and change-gated.""" + now = time.monotonic() + if now - self._web_last_grab_s < _GRAB_INTERVAL_S: + return None + self._web_last_grab_s = now + png_b64 = self._grab_plot_png_b64() + if png_b64 is None or png_b64 == self._web_last_png: + return None # nothing rendered yet, or the plot is unchanged + self._web_last_png = png_b64 + self._web_frame_seq += 1 + return { + "type": "frame", + "seq": self._web_frame_seq, + "mode": self._processing_mode.currentText(), + "png_b64": png_b64, + } + + def _grab_plot_png_b64(self) -> str | None: + """Render the currently-visible plot page to a base64 PNG (exactly as shown).""" + widget = self._plot_stack.currentWidget() + if widget is None or widget.width() <= 0 or widget.height() <= 0: + return None + pixmap = widget.grab() + if pixmap.isNull(): + return None + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + pixmap.save(buffer, "PNG") + return base64.b64encode(bytes(buffer.data())).decode() + + def _shutdown_web_ui(self) -> None: + """Stop the server (and its broadcaster) during teardown.""" + server = getattr(self, "_web_server", None) + if server is not None: + with contextlib.suppress(Exception): + server.stop() + self._web_server = None + self._web_controller = None + + @staticmethod + def _web_ui_port() -> int: + """Resolve the web UI port from the environment, defaulting to 8080.""" + raw = os.environ.get(_WEBUI_PORT_ENV, "").strip() + if not raw: + return _DEFAULT_PORT + try: + port = int(raw) + except ValueError: + return _DEFAULT_PORT + return port if 1 <= port <= 65535 else _DEFAULT_PORT diff --git a/python_app/hardware_full/laser_control/protocol.py b/python_app/hardware_full/laser_control/protocol.py index 368d5d8..92f7c3b 100644 --- a/python_app/hardware_full/laser_control/protocol.py +++ b/python_app/hardware_full/laser_control/protocol.py @@ -342,7 +342,10 @@ class Protocol: case TaskType.CHANGE_CURRENT_LD2: data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3 data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4 - data += _flipfour(_int_to_hex4(int(step * 100))) # Word 5 + # Word 5: current step encoded like LD1 and like min/max (current_ma_to_n), + # NOT int(step*100) — the latter was a copy/paste from temperature scaling + # and produced a different wire value than LD1 for the same physical step. + data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5 data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100 data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7 data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8 diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index d6b4023..16967e9 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -17,6 +17,7 @@ from python_app.models.run_config_validation import ( load_control_button_payload, load_ring_payload, load_switch_payload, + validate_combos, validate_gpr_model, ) @@ -431,6 +432,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: ) model.ensure_combos() + validate_combos( + model.combos, + input_positions=model.input_switch.positions, + output_positions=model.output_switch.positions, + ) return model diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index f00b149..854ea22 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -21,9 +21,10 @@ class ComboModel: class RadarSweepModel: """Sweep settings for LibreVNA acquisition.""" - # Keep schema defaults minimal/safe; operational values come from run_config.json. - start_hz: float = 0.0 - stop_hz: float = 0.0 + # A valid default range (stop > start) so a bare/default config is self-consistent; + # operational values come from run_config.json. (1 MHz .. 6 GHz mirrors the real configs.) + start_hz: float = 1_000_000.0 + stop_hz: float = 6_000_000_000.0 points: int = 1 if_bandwidth_hz: float = 1.0 power_dbm: float = -30.0 diff --git a/python_app/models/run_config_validation.py b/python_app/models/run_config_validation.py index cf2bea6..adbcfad 100644 --- a/python_app/models/run_config_validation.py +++ b/python_app/models/run_config_validation.py @@ -24,20 +24,18 @@ _MAX_COMBOS = 4096 def _require_int(payload: dict[str, Any], key: str, default: int) -> int: - """Read an integer field, rejecting JSON arrays/objects with a named ValueError. + """Read a strict JSON integer, treating an explicit ``null`` as 'use default'. - Bare ``int()`` raises ``TypeError`` on a list/dict, which escapes the - config-error contract; surface it as a ValueError naming the field instead. + Accept only a genuine JSON integer (not bool, not float, not numeric string): + silently truncating ``5.7`` or parsing ``"5"`` would hide a malformed config. + Mirrors ``run_config_codec._read_int`` so every config integer reads identically. """ value = payload.get(key, default) if value is None: # explicit JSON null -> use the default, never coerce return default - if isinstance(value, bool) or not isinstance(value, (int, float, str)): + if isinstance(value, bool) or not isinstance(value, int): raise ValueError(f"{key} must be a JSON integer") - try: - return int(value) - except (TypeError, ValueError) as exc: - raise ValueError(f"{key} must be a JSON integer") from exc + return value def _require_str(payload: dict[str, Any], key: str, default: str) -> str: @@ -131,8 +129,8 @@ def validate_sweep_model(sweep: RadarSweepModel) -> None: raise ValueError("radar.sweep.points must be an integer") if points <= 0: raise ValueError("radar.sweep.points must be > 0") - if float(sweep.stop_hz) < float(sweep.start_hz): - raise ValueError("radar.sweep.stop_hz must be >= radar.sweep.start_hz") + if float(sweep.stop_hz) <= float(sweep.start_hz): + raise ValueError("radar.sweep.stop_hz must be > radar.sweep.start_hz") def validate_gpr_model( @@ -173,6 +171,34 @@ def validate_gpr_model( seen_input_positions.add(input_pos) +def validate_combos( + combos: list[ComboModel], + *, + input_positions: int, + output_positions: int, +) -> None: + """Validate run combos against the configured switch dimensions. + + Each combo's input/output must index a real switch position, and no + ``input:output`` pair may repeat — an out-of-range or duplicate combo is a + config error that would otherwise produce missing or doubled traces downstream. + """ + seen: set[tuple[int, int]] = set() + for combo in combos: + if not 0 <= int(combo.input) < int(input_positions): + raise ValueError( + f"run.combos input {combo.input} is out of range [0, {input_positions})" + ) + if not 0 <= int(combo.output) < int(output_positions): + raise ValueError( + f"run.combos output {combo.output} is out of range [0, {output_positions})" + ) + pair = (int(combo.input), int(combo.output)) + if pair in seen: + raise ValueError(f"run.combos contains duplicate combo {combo.input}:{combo.output}") + seen.add(pair) + + def parse_combos_from_text(text: str) -> list[ComboModel]: """Parse UI combos string in `input:output,input:output` format.""" cleaned = text.strip() diff --git a/python_app/orchestration/gpr_locator.py b/python_app/orchestration/gpr_locator.py index 86c75c5..2097285 100644 --- a/python_app/orchestration/gpr_locator.py +++ b/python_app/orchestration/gpr_locator.py @@ -65,3 +65,52 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray: return centers[:, :3] return np.zeros((0, 3), dtype=np.float32) + + +def apply_object_draw_limits( + rows: np.ndarray, + limits: tuple[int, int] | None, +) -> np.ndarray: + """Apply the object count/top-M drawing rules to already-filtered `[x, z, score]` rows. + + `limits` is `(max_detected_objects, draw_top_objects)`, or `None` to disable + (legacy GPR). When more than ``max_detected_objects`` survive, ALL are hidden + (the scene is too cluttered to be meaningful); otherwise the top ``draw_top_objects`` + rows are kept (rows arrive already sorted by score descending). + """ + if limits is None or rows.size == 0: + return rows + max_detected_objects, draw_top_objects = limits + if rows.shape[0] > int(max_detected_objects): + return np.zeros((0, rows.shape[1]), dtype=rows.dtype) + return rows[: max(0, int(draw_top_objects))] + + +def filter_object_rows( + rows: np.ndarray, + *, + min_score: float, + x_bounds: tuple[float, float], + z_bounds: tuple[float, float], + draw_limits: tuple[int, int] | None, +) -> np.ndarray: + """Filter `[x_m, z_m, score]` object rows for display/broadcast. + + Drops non-finite rows, rows below ``min_score`` (the caller passes the mode's own + threshold — a normalized float for coherent GPR, a pair count for legacy GPR; the + comparison is identical either way), and rows outside the visible X/Z window, then + applies ``draw_limits``. Mode-agnostic: all semantics enter through the parameters. + """ + if rows.size == 0: + return rows + x_min, x_max = x_bounds + z_min, z_max = z_bounds + visible_mask = ( + np.all(np.isfinite(rows[:, :3]), axis=1) + & (rows[:, 2] >= min_score) + & (rows[:, 0] >= x_min) + & (rows[:, 0] <= x_max) + & (rows[:, 1] >= z_min) + & (rows[:, 1] <= z_max) + ) + return apply_object_draw_limits(rows[visible_mask], draw_limits) diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 78ce2f6..9186964 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -57,6 +57,12 @@ class ProcessingLiveConfig: # Locator filter parameters consumed by the C++ TCP locator server. gpr_min_visible_score: float = 0.0 legacy_gpr_min_visible_pair_count: float = 0.0 + # Visible X/Z window (metres). The locator and the desktop plot both clip + # detected objects to this window, so the socket broadcasts only what is shown. + gpr_visible_x_min_m: float = -2.0 + gpr_visible_x_max_m: float = 2.0 + gpr_visible_z_min_m: float = 0.0 + gpr_visible_z_max_m: float = 14.0 # When true, the C++ data_processor ignores socket-supplied vlc updates # and keeps using `gpr_speed_m_s` from this file. ignore_socket_speed: bool = False @@ -116,6 +122,10 @@ class ProcessingLiveConfig: "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), "gpr_min_visible_score": float(self.gpr_min_visible_score), "legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count), + "gpr_visible_x_min_m": float(self.gpr_visible_x_min_m), + "gpr_visible_x_max_m": float(self.gpr_visible_x_max_m), + "gpr_visible_z_min_m": float(self.gpr_visible_z_min_m), + "gpr_visible_z_max_m": float(self.gpr_visible_z_max_m), "ignore_socket_speed": bool(self.ignore_socket_speed), "reprocess_current_result": bool(self.reprocess_current_result), "history_command_seq": int(self.history_command_seq), diff --git a/python_app/orchestration/restart_policy.py b/python_app/orchestration/restart_policy.py new file mode 100644 index 0000000..a6f8af0 --- /dev/null +++ b/python_app/orchestration/restart_policy.py @@ -0,0 +1,47 @@ +"""Crash auto-restart back-off policy (pure, GUI-independent).""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RestartPolicy: + """Decide when to relaunch a crashed pipeline — retry forever with capped back-off. + + This is an unattended appliance, so the pipeline never permanently gives up. The + wait between restart attempts grows with the number of consecutive failures (so a + persistently broken pipeline is not hammered) but is capped at ``max_interval_s``, + and the failure streak resets to zero once genuine data flows again. A burst of + crash signals within the current back-off window collapses to a single restart. + """ + + min_interval_s: float = 3.0 + max_interval_s: float = 60.0 + backoff_factor: float = 2.0 + + def backoff_for(self, consecutive_failures: int) -> float: + """Return the seconds to wait before the next restart for this failure streak. + + ``consecutive_failures`` is the number of restarts already attempted without a + recovery: 0 → ``min_interval_s``, then each additional failure multiplies the + wait by ``backoff_factor``, capped at ``max_interval_s``. + """ + if consecutive_failures <= 0: + return self.min_interval_s + # Beyond this many doublings the wait is always capped; clamp the exponent so a + # long streak cannot overflow ``factor ** n``. + max_exponent = max(1, math.ceil(math.log(self.max_interval_s / self.min_interval_s, self.backoff_factor))) + exponent = min(int(consecutive_failures), max_exponent) + return min(self.min_interval_s * (self.backoff_factor ** exponent), self.max_interval_s) + + def should_restart_now( + self, + *, + now_s: float, + last_restart_s: float, + consecutive_failures: int, + ) -> bool: + """Return whether enough back-off has elapsed since the last restart to retry.""" + return now_s - last_restart_s >= self.backoff_for(consecutive_failures) diff --git a/python_app/orchestration/shm/binary_cursor.py b/python_app/orchestration/shm/binary_cursor.py index ea824ce..510ed01 100644 --- a/python_app/orchestration/shm/binary_cursor.py +++ b/python_app/orchestration/shm/binary_cursor.py @@ -1,4 +1,9 @@ -"""Byte-wise cursor utilities for decoding binary ring payloads.""" +"""Byte-wise cursor utilities for decoding binary ring payloads. + +Every read is bounds-checked and raises :class:`ValueError` on a truncated or +malformed payload, so decoders surface a single, catchable error type for any +corruption (rather than leaking ``struct.error``/``UnicodeDecodeError``). +""" from __future__ import annotations @@ -6,48 +11,56 @@ import struct class ByteCursor: - """Read primitive values from bytes while tracking offset.""" + """Read primitive values from bytes while tracking offset (bounds-checked).""" def __init__(self, payload: bytes) -> None: """Create cursor at start of payload.""" self.payload = payload self.offset = 0 + def _take(self, size: int) -> bytes: + """Consume ``size`` bytes, raising ValueError if the payload is too short.""" + end = self.offset + size + if size < 0 or end > len(self.payload): + raise ValueError( + f"truncated payload: need {size} bytes at offset {self.offset}, " + f"only {len(self.payload) - self.offset} remain" + ) + data = self.payload[self.offset : end] + self.offset = end + return data + def read_u8(self) -> int: """Read unsigned 8-bit integer.""" - value = struct.unpack_from(" int: """Read unsigned 16-bit integer.""" - value = struct.unpack_from(" int: """Read unsigned 32-bit integer.""" - value = struct.unpack_from(" int: """Read unsigned 64-bit integer.""" - value = struct.unpack_from(" float: """Read 32-bit float.""" - value = struct.unpack_from(" bytes: - """Read raw byte slice of fixed size.""" - data = self.payload[self.offset : self.offset + size] - self.offset += size - return data + """Read raw byte slice of fixed size (bounds-checked).""" + return self._take(size) + + def read_str(self, size: int) -> str: + """Read a UTF-8 string of ``size`` bytes, raising ValueError on bad UTF-8.""" + raw = self._take(size) + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("invalid UTF-8 in payload string") from exc def remaining_bytes(self) -> int: """Return unread byte count.""" diff --git a/python_app/orchestration/shm/decoder.py b/python_app/orchestration/shm/decoder.py index c0eb49c..cfc3e6b 100644 --- a/python_app/orchestration/shm/decoder.py +++ b/python_app/orchestration/shm/decoder.py @@ -78,7 +78,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection: """Decode one result payload from stream.""" kind = cursor.read_u8() name_size = cursor.read_u16() - name = cursor.read_bytes(name_size).decode("utf-8") + name = cursor.read_str(name_size) if kind == 1: point_count = cursor.read_u32() diff --git a/python_app/orchestration/shm/ring_reader.py b/python_app/orchestration/shm/ring_reader.py index 0d69a95..b8a3a94 100644 --- a/python_app/orchestration/shm/ring_reader.py +++ b/python_app/orchestration/shm/ring_reader.py @@ -35,12 +35,20 @@ class ShmRingReader: self._wait_for_ring_file(timeout_s=open_timeout_s, poll_s=open_poll_s) self._file = self._path.open("r+b", buffering=0) - self._mmap = mmap.mmap(self._file.fileno(), 0) - self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002) + self._mmap: mmap.mmap | None = None + try: + self._mmap = mmap.mmap(self._file.fileno(), 0) + self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002) + except BaseException: + # A fail-fast open (absent/incompatible ring) must not leak the fd/mapping. + self.close() + raise def close(self) -> None: """Close mmap and file handle.""" - self._mmap.close() + if self._mmap is not None: + self._mmap.close() + self._mmap = None self._file.close() def pop_payload(self) -> bytes | None: @@ -103,6 +111,45 @@ class ShmRingReader: return None return decode_result_collection(payload) + def peek_latest_payload(self) -> bytes | None: + """Return the most recently published payload WITHOUT consuming it. + + Reads the newest slot through the seqlock and never advances `read_seq`, so a + viewer can peek the freshest frame while the ring's real consumer keeps its own + cursor — the two coexist without stealing each other's payloads. Inherently + latest-wins: always the freshest published frame, or `None` when nothing has + been published yet or the slot is being overwritten at this instant. + """ + write_seq = self._read_u64(24) + if write_seq == 0: + return None + + latest_seq = write_seq - 1 + index = latest_seq % self.capacity + slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self.slot_size_bytes) + + # Seqlock read with NO cursor advance: accept the slot only if its sequence + # equals the published value both before and after the copy (i.e. the producer + # did not lap this slot mid-read). Never touch read_seq, so the real consumer + # is undisturbed. + if self._read_u64(slot_offset + 8) != latest_seq + 1: + return None + payload_size = self._read_u32(slot_offset) + if payload_size > self.slot_size_bytes: + return None + payload_offset = slot_offset + _SLOT_HEADER_SIZE + payload = bytes(self._mmap[payload_offset : payload_offset + payload_size]) + if self._read_u64(slot_offset + 8) != latest_seq + 1: + return None + return payload + + def peek_latest_result_collection(self) -> ResultCollection | None: + """Return the most recently published result collection without consuming it.""" + payload = self.peek_latest_payload() + if payload is None: + return None + return decode_result_collection(payload) + def drop_all(self) -> int: """Mark all unread slots as consumed and return number of dropped payloads.""" write_seq = self._read_u64(24) diff --git a/python_app/orchestration/shm/ring_writer.py b/python_app/orchestration/shm/ring_writer.py index 2327b5b..4bbf6f9 100644 --- a/python_app/orchestration/shm/ring_writer.py +++ b/python_app/orchestration/shm/ring_writer.py @@ -98,9 +98,12 @@ class ShmRingWriter: write_seq = self._read_u64(24) read_seq = self._read_u64(32) if max(0, write_seq - read_seq) >= self._capacity: - self._write_u64(32, read_seq + 1) - dropped = self._read_u64(40) - self._write_u64(40, dropped + 1) + # Advance the consumer cursor past the slot we are about to overwrite, but + # re-read it first and move it only forward: a concurrent reader may have + # already advanced it, and clobbering that backward would re-deliver an + # already-consumed slot as a duplicate. + self._write_u64(32, max(self._read_u64(32), read_seq + 1)) + self._write_u64(40, self._read_u64(40) + 1) index = write_seq % self._capacity slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes) diff --git a/python_app/scripts/kamil_adc_raw_producer.py b/python_app/scripts/kamil_adc_raw_producer.py index a27dcb4..b2a30aa 100644 --- a/python_app/scripts/kamil_adc_raw_producer.py +++ b/python_app/scripts/kamil_adc_raw_producer.py @@ -136,6 +136,7 @@ def main() -> int: return 0 # asked to stop before a device became available collection_id = 1 + publish_failures = 0 while not stop_requested.is_set(): collection_start = time.monotonic() capture_start_ns = time.monotonic_ns() @@ -193,14 +194,17 @@ def main() -> int: capture_end_ns=time.monotonic_ns(), ) payload = serialize_trace_collection(collection, RAW_MAGIC) - if not raw_writer.push(payload): - raise RuntimeError( - f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}" - ) - if not raw_tap_writer.push(payload): - raise RuntimeError( - f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}" - ) + if raw_writer.push(payload): + raw_tap_writer.push(payload) # best-effort GUI tap; never fatal + else: + # Oversized payload vs the ring slot is a persistent config error, not + # a device fault: log (throttled) and skip rather than killing the producer. + publish_failures += 1 + if publish_failures == 1 or publish_failures % 100 == 0: + logger.error( + "Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)", + len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures, + ) if not config.runtime.continuous: break diff --git a/python_app/scripts/matrix_raw_producer.py b/python_app/scripts/matrix_raw_producer.py index 5a622b5..409aadc 100644 --- a/python_app/scripts/matrix_raw_producer.py +++ b/python_app/scripts/matrix_raw_producer.py @@ -117,6 +117,7 @@ def main() -> int: if radar is None: return 0 # asked to stop before a device became available collection_id = 1 + publish_failures = 0 while not stop_requested.is_set(): collection_start = time.monotonic() try: @@ -133,14 +134,18 @@ def main() -> int: continue payload = serialize_trace_collection(collection, RAW_MAGIC) - if not raw_writer.push(payload): - raise RuntimeError( - f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}" - ) - if not raw_tap_writer.push(payload): - raise RuntimeError( - f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}" - ) + if raw_writer.push(payload): + raw_tap_writer.push(payload) # best-effort GUI tap; never fatal + else: + # An oversized payload vs the ring slot is a persistent config error, + # not a device fault: log it (throttled) and skip the collection rather + # than letting a RuntimeError escape the loop and kill the producer. + publish_failures += 1 + if publish_failures == 1 or publish_failures % 100 == 0: + logger.error( + "Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)", + len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures, + ) if not config.runtime.continuous: break collection_duration_s = time.monotonic() - collection_start diff --git a/python_app/tests/test_gui_profile_codec.py b/python_app/tests/test_gui_profile_codec.py index e29f11c..fa574ab 100644 --- a/python_app/tests/test_gui_profile_codec.py +++ b/python_app/tests/test_gui_profile_codec.py @@ -30,6 +30,42 @@ class GuiProfileCodecTest(unittest.TestCase): 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") + def test_default_profile_round_trips_idempotently(self) -> None: + # Full-subtree idempotence catches field-drop/mis-map regressions across every + # sub-model, which the single combo_filter round-trip above cannot. + once = GuiProfileModel().to_dict() + twice = GuiProfileModel.from_dict(once).to_dict() + self.assertEqual(once["gui"], twice["gui"]) + + def test_missing_gui_section_yields_none(self) -> None: + self.assertIsNone(GuiProfileModel.from_dict({}).gui) + + def test_unsupported_version_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "version"): + GuiProfileModel.from_dict({"gui": {"version": 2}}) + + def test_invalid_combo_mode_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "combo_mode"): + GuiProfileModel.from_dict({"gui": {"switches": {"combo_mode": "bogus"}}}) + + def test_wrong_typed_field_is_rejected(self) -> None: + with self.assertRaises(ValueError): # combos_text must be a JSON string, not a number + GuiProfileModel.from_dict({"gui": {"switches": {"combos_text": 123}}}) + + def test_legacy_gpr_payload_migrates_selected_mode(self) -> None: + # An old payload that selects 'gpr' but carries a legacy root gpr.mode is migrated. + decoded = GuiProfileModel.from_dict({ + "gui": {"processing": {"selected_mode": "gpr"}}, + "gpr": {"relative_permittivity": 4.0, "mode": "point"}, + }) + assert decoded.gui is not None + self.assertEqual(decoded.gui.processing.selected_mode, "legacy_gpr") + + def test_modern_gpr_selection_is_not_migrated(self) -> None: + decoded = GuiProfileModel.from_dict({"gui": {"processing": {"selected_mode": "gpr"}}}) + assert decoded.gui is not None + self.assertEqual(decoded.gui.processing.selected_mode, "gpr") + if __name__ == "__main__": unittest.main() diff --git a/python_app/tests/test_kamil_adc_neutral_preprocess.py b/python_app/tests/test_kamil_adc_neutral_preprocess.py index c965252..2b5d4df 100644 --- a/python_app/tests/test_kamil_adc_neutral_preprocess.py +++ b/python_app/tests/test_kamil_adc_neutral_preprocess.py @@ -60,6 +60,46 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "kamil_adc"): build_kamil_adc_neutral_s21_sets(config, point_count=4) + @staticmethod + def _kamil_config() -> RunConfigModel: + return RunConfigModel.from_dict( + { + "radar": { + "model": "kamil_adc", + "sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0, + "if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0}, + }, + "switches": {"port1": {"positions": 1}, "port2": {"positions": 2}}, + "run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]}, + } + ) + + def test_point_count_zero_or_negative_raises(self) -> None: + config = self._kamil_config() + for bad in (0, -1): + with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"): + build_kamil_adc_neutral_s21_sets(config, point_count=bad) + + def test_single_point_sweep(self) -> None: + calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1) + for trace in calibration.traces: + self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0]) + self.assertEqual(trace.s21.shape, (1,)) + + def test_dtypes_are_float32_and_complex64(self) -> None: + calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4) + trace = calibration.traces[0] + self.assertEqual(trace.frequency_hz.dtype, np.float32) + self.assertEqual(trace.s21.dtype, np.complex64) + self.assertEqual(trace.s11.dtype, np.complex64) + + def test_calibration_s21_is_a_nonzero_divisor(self) -> None: + # The C++ through-calibrator divides measured/calibration, so calibration S21 + # must never be zero — that is the whole point of the '1+0j neutral' contract. + calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4) + for trace in calibration.traces: + self.assertTrue(bool(np.all(trace.s21 != 0))) + if __name__ == "__main__": unittest.main() diff --git a/python_app/tests/test_kamil_adc_service.py b/python_app/tests/test_kamil_adc_service.py index 5379892..ed0c66f 100644 --- a/python_app/tests/test_kamil_adc_service.py +++ b/python_app/tests/test_kamil_adc_service.py @@ -140,6 +140,23 @@ class KamilAdcTtyReaderTest(unittest.TestCase): finally: self._close(master_fd, slave_fd, reader) + def test_corrupt_frame_fails_fast_without_resync(self) -> None: + """A garbage frame (bad marker) mid-stream surfaces on read; the reader does + NOT silently resync — fail-fast lets the producer die and the supervisor relaunch.""" + master_fd, slave_fd, reader = self._open_pty_reader() + try: + os.write( + master_fd, + _start_frame() + + _point_frame(1, 10, -1) + + _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A) + + _start_frame(), + ) + with self.assertRaises((ValueError, RuntimeError)): + reader.read_sweep(timeout_s=1.0) + finally: + self._close(master_fd, slave_fd, reader) + def test_no_completed_sweep_times_out(self) -> None: master_fd, slave_fd, reader = self._open_pty_reader() try: diff --git a/python_app/tests/test_laser_control_protocol.py b/python_app/tests/test_laser_control_protocol.py index 2e40a93..14230a4 100644 --- a/python_app/tests/test_laser_control_protocol.py +++ b/python_app/tests/test_laser_control_protocol.py @@ -17,7 +17,9 @@ DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = ( "7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600" ) DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = ( - "7777ff3702003d2acc2c0500881318ab3d2affa50a00000a8000000a80005106" + # Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the + # step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005). + "7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406" ) @@ -128,6 +130,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX) self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX) + def test_ld1_and_ld2_encode_current_step_identically(self) -> None: + # Regression guard for the LD2 step-scale bug: both current-variation channels + # must encode the step with the same scale (current_ma_to_n), like min/max. + params = dict(static_temp1=28.0, static_temp2=28.9, static_current1=33.0, static_current2=35.0, + min_value=33.0, max_value=35.0, step=0.05, time_step=50, delay_time=10, + message_id=DEVICE_MAIN_MESSAGE_ID, pi_coeff1_p=2560, pi_coeff1_i=128, + pi_coeff2_p=2560, pi_coeff2_i=128) + ld1 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD1, **params) + ld2 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD2, **params) + # Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step). + self.assertEqual(ld1[10:12], ld2[10:12]) + def test_start_sequence_matches_device_main_order(self) -> None: fake_protocol = _FakeProtocol() controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) @@ -235,6 +249,37 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): "message_id": DEVICE_MAIN_MESSAGE_ID, }, ) + # The variation handoff itself (type + params), not just call order, must be right. + variation_payload = controller.calls[3][1] + self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1) + self.assertEqual(variation_payload["params"]["step"], 0.05) + self.assertEqual(variation_payload["params"]["min_value"], 33.0) + self.assertEqual(variation_payload["params"]["max_value"], 35.0) + + def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None: + config = self._kamil_config( + { + "enabled": True, + "port": "/dev/ttyUSB0", + "mode": "manual", + "manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0}, + } + ) + with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController): + applied = apply_kamil_adc_laser_control(config) + self.assertTrue(applied) + controller = _FakeLaserController.instances[0] + self.assertEqual([name for name, _ in controller.calls], ["connect", "reset", "set_manual_mode", "disconnect"]) + self.assertEqual(controller.calls[2][1]["current1"], 30.0) + + def test_apply_radar_unknown_variation_type_raises(self) -> None: + config = self._kamil_config( + {"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation", + "variation": {"variation_type": "NOT_A_REAL_TYPE"}} + ) + with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController): + with self.assertRaisesRegex(ValueError, "variation_type"): + apply_kamil_adc_laser_control(config) def test_apply_radar_skips_disabled_laser_control(self) -> None: config = self._kamil_config({"enabled": False}) diff --git a/python_app/tests/test_orchestration_recovery.py b/python_app/tests/test_orchestration_recovery.py new file mode 100644 index 0000000..13a720c --- /dev/null +++ b/python_app/tests/test_orchestration_recovery.py @@ -0,0 +1,197 @@ +"""Orchestration & recovery tests. + +Pins the agreed semantics: + * crash auto-restart retries FOREVER with capped exponential back-off (never gives + up — unattended appliance); the failure streak resets when data flows again; + * config writes are atomic — a serialization failure leaves no partial output and + never corrupts an existing config; + * the acquisition producer command is selected by radar.model; + * process exit reports classify clean vs unexpected exits correctly. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import time +import unittest +from pathlib import Path + +from python_app.models.run_config_model import RunConfigModel +from python_app.orchestration.config_writer import ConfigWriter +from python_app.orchestration.process_supervisor import ProcessExitReport, ProcessSupervisor +from python_app.orchestration.restart_policy import RestartPolicy + + +class RestartPolicyTest(unittest.TestCase): + def test_backoff_grows_and_caps(self) -> None: + policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0, backoff_factor=2.0) + self.assertEqual(policy.backoff_for(0), 3.0) + self.assertEqual(policy.backoff_for(1), 6.0) + self.assertEqual(policy.backoff_for(2), 12.0) + self.assertEqual(policy.backoff_for(3), 24.0) + self.assertEqual(policy.backoff_for(100), 60.0) # capped + + def test_never_gives_up_even_after_huge_streak(self) -> None: + # No attempt cap: a long failure streak still yields a finite, capped wait. + policy = RestartPolicy() + self.assertEqual(policy.backoff_for(10_000), policy.max_interval_s) + + def test_should_restart_respects_backoff_window(self) -> None: + policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0) + # First failure (streak 0): needs >= 3s since last restart. + self.assertFalse(policy.should_restart_now(now_s=102.0, last_restart_s=100.0, consecutive_failures=0)) + self.assertTrue(policy.should_restart_now(now_s=103.0, last_restart_s=100.0, consecutive_failures=0)) + # After 2 failures the window is 12s. + self.assertFalse(policy.should_restart_now(now_s=111.0, last_restart_s=100.0, consecutive_failures=2)) + self.assertTrue(policy.should_restart_now(now_s=112.0, last_restart_s=100.0, consecutive_failures=2)) + + def test_first_restart_is_immediate(self) -> None: + # last_restart defaults to 0.0 in the mixin, so the very first crash restarts now. + self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0)) + + +class ConfigWriterTest(unittest.TestCase): + def setUp(self) -> None: + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.root = Path(self._dir.name) + self.writer = ConfigWriter(self.root / "runtime") + + def test_writes_loadable_config_and_leaves_no_tmp(self) -> None: + out = self.root / "run_config.json" + self.writer.write(RunConfigModel(), out) + self.assertTrue(out.exists()) + self.assertFalse(out.with_suffix(".json.tmp").exists()) + RunConfigModel.from_dict(json.loads(out.read_text())) # round-trips back through the schema + + def test_nan_fails_loudly_without_corrupting_existing(self) -> None: + out = self.root / "run_config.json" + self.writer.write(RunConfigModel(), out) + original = out.read_text() + + broken = RunConfigModel() + broken.radar.sweep.if_bandwidth_hz = float("nan") + with self.assertRaises(ValueError): # allow_nan=False + self.writer.write(broken, out) + + self.assertEqual(out.read_text(), original) # existing config untouched + self.assertFalse(out.with_suffix(".json.tmp").exists()) # no half-written tmp left + + +class RadarModelCommandTest(unittest.TestCase): + def setUp(self) -> None: + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.root = Path(self._dir.name) + self.supervisor = ProcessSupervisor(self.root) + + def _config_with_model(self, model: str) -> Path: + path = self.root / "cfg.json" + path.write_text(json.dumps({"radar": {"model": model}})) + return path + + def test_read_radar_model(self) -> None: + self.assertEqual(ProcessSupervisor._read_radar_model(self._config_with_model("kamil_adc")), "kamil_adc") + + def test_read_radar_model_defaults_on_malformed(self) -> None: + path = self.root / "bad.json" + path.write_text("[]") # not an object + self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna") + + def test_matrix_producer_for_multi_and_sn9000(self) -> None: + for model in ("librevna_multi", "sn9000"): + cmd = self.supervisor._acquisition_command(self._config_with_model(model)) + self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"]) + + def test_kamil_producer_for_kamil_adc(self) -> None: + cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc")) + self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"]) + + def test_native_orchestrator_for_librevna(self) -> None: + cmd = self.supervisor._acquisition_command(self._config_with_model("librevna")) + self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator")) + + +class ProcessExitReportTest(unittest.TestCase): + @staticmethod + def _report(*, code: int, clean: bool) -> ProcessExitReport: + return ProcessExitReport( + name="data_processor", + command=["x"], + working_directory=Path("/tmp"), + return_code=code, + stdout_path=Path("/nonexistent.out"), + stderr_path=Path("/nonexistent.err"), + expected_clean_exit=clean, + ) + + def test_clean_exit_is_info(self) -> None: + report = self._report(code=0, clean=True) + self.assertEqual(report.level, "INFO") + self.assertIn("completed normally", report.format()) + + def test_nonzero_exit_is_error(self) -> None: + report = self._report(code=3, clean=False) + self.assertEqual(report.level, "ERROR") + self.assertIn("exited with code 3", report.format()) + + def test_unexpected_zero_exit_is_error(self) -> None: + report = self._report(code=0, clean=False) + self.assertEqual(report.level, "ERROR") + self.assertIn("exited unexpectedly with code 0", report.format()) + + +class SupervisorLifecycleTest(unittest.TestCase): + def setUp(self) -> None: + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.supervisor = ProcessSupervisor(Path(self._dir.name)) + self.addCleanup(self.supervisor.stop_all) + + def _await_reports(self, timeout_s: float = 3.0) -> list[ProcessExitReport]: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + reports = self.supervisor.collect_exit_reports() + if reports: + return reports + time.sleep(0.02) + return [] + + def test_spawn_alive_then_stop(self) -> None: + self.supervisor._spawn("data_preprocessor", + [sys.executable, "-c", "import time; time.sleep(30)"], + allow_clean_exit=False) + self.assertTrue(self.supervisor.is_running()) + self.assertIn("data_preprocessor", self.supervisor.pids()) + self.supervisor.stop() + self.assertFalse(self.supervisor.is_running()) + + def test_unexpected_exit_reported_as_error(self) -> None: + self.supervisor._spawn("data_processor", + [sys.executable, "-c", "import sys; sys.exit(3)"], + allow_clean_exit=False) + reports = self._await_reports() + self.assertEqual(len(reports), 1) + self.assertEqual(reports[0].return_code, 3) + self.assertEqual(reports[0].level, "ERROR") + + def test_clean_exit_reported_as_info(self) -> None: + self.supervisor._spawn("sweep_orchestrator", + [sys.executable, "-c", "import sys; sys.exit(0)"], + allow_clean_exit=True) + reports = self._await_reports() + self.assertEqual(len(reports), 1) + self.assertTrue(reports[0].expected_clean_exit) + self.assertEqual(reports[0].level, "INFO") + + def test_stale_pid_guard_rejects_unrelated_processes(self) -> None: + # The reap guard must never kill a recycled PID that is not one of our binaries. + self.assertFalse(self.supervisor._is_stale_pipeline_pid(2_000_000_000)) # no such pid + self.assertFalse(self.supervisor._is_stale_pipeline_pid(os.getpid())) # the test runner + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/tests/test_processing.py b/python_app/tests/test_processing.py new file mode 100644 index 0000000..c27a7b4 --- /dev/null +++ b/python_app/tests/test_processing.py @@ -0,0 +1,296 @@ +"""Processing-interpretation tests across processor modes. + +Pins the agreed semantics (not just current behaviour): + * GPR object filtering is mode-agnostic — keep finite rows with score >= threshold + (a normalized float for coherent GPR, a pair count for legacy GPR; same compare) + inside the visible X/Z window, then apply draw limits. When more than + max_detected_objects survive, ALL are hidden; legacy GPR passes draw_limits=None + (count rules disabled) but is otherwise filtered identically — so heatmap markers + match the objects-only view in both modes. + * B-scan level/colormap/mean-subtraction/history transforms are deterministic. + * ProcessingLiveConfig normalizes optional position lists to concrete int lists. +""" + +from __future__ import annotations + +import math +import unittest +from collections import deque + +import numpy as np + +from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import ( + apply_mean_ascan_subtraction, + bscan_levels, + bscan_lookup_table, + build_lut, + rebuild_bscan_history_from_results, +) +from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin +from python_app.models.dataset_model import ( + ComboKey, + ResultBlock, + ResultCollection, + ResultPayload, +) +from python_app.orchestration.gpr_locator import ( + apply_object_draw_limits, + collection_has_gpr_payloads, + collection_payload_by_name, + collection_payloads_by_prefix, + filter_object_rows, + gpr_object_rows, +) +from python_app.orchestration.live_processing_config import ProcessingLiveConfig + + +def _table(name: str, rows) -> ResultPayload: + return ResultPayload(processing_name=name, kind=4, table=np.asarray(rows, dtype=np.float32)) + + +def _collection(payloads) -> ResultCollection: + return ResultCollection(collection_id=1, monotonic_ns=1, collection_payloads=list(payloads)) + + +# --------------------------------------------------------------------------- # +# Shared result-collection lookup helpers +# --------------------------------------------------------------------------- # +class CollectionLookupTest(unittest.TestCase): + def test_payload_by_name_matches_name_and_optional_kind(self) -> None: + col = _collection([_table("gpr_points", [[0, 0, 1]]), _table("gpr_region_centers", [[1, 1, 2, 9]])]) + self.assertIs(collection_payload_by_name(col, "gpr_points"), col.collection_payloads[0]) + self.assertIsNone(collection_payload_by_name(col, "missing")) + self.assertIsNone(collection_payload_by_name(col, "gpr_points", kind=3)) # wrong kind + + def test_payloads_by_prefix_returns_all_in_order(self) -> None: + col = _collection([ + ResultPayload(processing_name="gpr_region_mask_0", kind=3, image=np.zeros((1, 1), dtype=np.float32)), + ResultPayload(processing_name="gpr_region_mask_1", kind=3, image=np.zeros((1, 1), dtype=np.float32)), + _table("gpr_points", [[0, 0, 1]]), + ]) + masks = collection_payloads_by_prefix(col, "gpr_region_mask_", kind=3) + self.assertEqual([p.processing_name for p in masks], ["gpr_region_mask_0", "gpr_region_mask_1"]) + self.assertEqual(collection_payloads_by_prefix(col, "nope_"), []) + + def test_has_gpr_payloads(self) -> None: + self.assertTrue(collection_has_gpr_payloads(_collection([_table("gpr_points", [[0, 0, 1]])]))) + self.assertFalse(collection_has_gpr_payloads(_collection([_table("pass_through", [[0, 0, 1]])]))) + self.assertFalse(collection_has_gpr_payloads(_collection([]))) + + +# --------------------------------------------------------------------------- # +# GPR object row extraction +# --------------------------------------------------------------------------- # +class GprObjectRowsTest(unittest.TestCase): + def test_extracts_first_three_columns_of_points(self) -> None: + rows = gpr_object_rows(_collection([_table("gpr_points", [[1, 2, 3], [4, 5, 6]])])) + self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32))) + + def test_falls_back_to_region_centers_and_drops_extra_columns(self) -> None: + # region_centers is [x, z, score, pixel_count]; only the first 3 cols are used. + rows = gpr_object_rows(_collection([_table("gpr_region_centers", [[1, 2, 3, 99]])])) + self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3]], dtype=np.float32))) + + def test_empty_when_no_object_payloads(self) -> None: + self.assertEqual(gpr_object_rows(_collection([_table("gpr_accumulator", [[1, 2, 3]])])).shape, (0, 3)) + + def test_rejects_table_with_too_few_columns(self) -> None: + self.assertEqual(gpr_object_rows(_collection([_table("gpr_points", [[1, 2]])])).shape, (0, 3)) + + +# --------------------------------------------------------------------------- # +# Object draw limits (hide-all-when-over, then top-M) +# --------------------------------------------------------------------------- # +class ApplyObjectDrawLimitsTest(unittest.TestCase): + @staticmethod + def _rows(n: int) -> np.ndarray: + return np.column_stack([np.arange(n), np.arange(n), np.arange(n)]).astype(np.float32) + + def test_none_limits_passes_through(self) -> None: + rows = self._rows(5) + self.assertTrue(np.array_equal(apply_object_draw_limits(rows, None), rows)) + + def test_hides_all_when_over_max(self) -> None: + self.assertEqual(apply_object_draw_limits(self._rows(6), (5, 3)).shape, (0, 3)) + + def test_keeps_top_m_when_within_max(self) -> None: + out = apply_object_draw_limits(self._rows(4), (5, 2)) + self.assertEqual(out.shape, (2, 3)) + self.assertTrue(np.array_equal(out, self._rows(4)[:2])) + + def test_max_zero_hides_any_objects(self) -> None: + # max_detected_objects == 0 means "hide all" (any object exceeds it). + self.assertEqual(apply_object_draw_limits(self._rows(1), (0, 5)).shape, (0, 3)) + + def test_empty_input_passes_through(self) -> None: + empty = np.zeros((0, 3), dtype=np.float32) + self.assertEqual(apply_object_draw_limits(empty, (5, 3)).shape, (0, 3)) + + +# --------------------------------------------------------------------------- # +# Mode-agnostic object filtering (the core both modes + the locator share) +# --------------------------------------------------------------------------- # +class FilterObjectRowsTest(unittest.TestCase): + BOUNDS = {"x_bounds": (-2.0, 2.0), "z_bounds": (0.0, 10.0)} + + def _filter(self, rows, *, min_score, draw_limits=None): + return filter_object_rows(np.asarray(rows, dtype=np.float32), min_score=min_score, + draw_limits=draw_limits, **self.BOUNDS) + + def test_keeps_in_window_and_at_or_above_threshold(self) -> None: + out = self._filter([[0.0, 5.0, 0.5], [1.0, 1.0, 0.9]], min_score=0.5) # score==threshold kept (inclusive) + self.assertEqual(out.shape[0], 2) + + def test_drops_below_threshold(self) -> None: + out = self._filter([[0.0, 5.0, 0.4]], min_score=0.5) + self.assertEqual(out.shape[0], 0) + + def test_window_bounds_are_inclusive(self) -> None: + out = self._filter([[2.0, 10.0, 1.0], [-2.0, 0.0, 1.0]], min_score=0.0) # exactly on each edge + self.assertEqual(out.shape[0], 2) + + def test_drops_outside_window(self) -> None: + out = self._filter([[2.001, 5.0, 1.0], [0.0, 10.001, 1.0]], min_score=0.0) + self.assertEqual(out.shape[0], 0) + + def test_drops_non_finite_rows(self) -> None: + out = self._filter([[np.nan, 5.0, 1.0], [0.0, np.inf, 1.0], [0.0, 5.0, 1.0]], min_score=0.0) + self.assertEqual(out.shape[0], 1) + + def test_legacy_pair_count_threshold_uses_same_compare(self) -> None: + # legacy GPR passes an integer pair-count threshold; the >= compare is identical. + out = self._filter([[0.0, 5.0, 3.0], [0.0, 5.0, 2.0]], min_score=3, draw_limits=None) + self.assertTrue(np.array_equal(out, np.array([[0.0, 5.0, 3.0]], dtype=np.float32))) + + def test_draw_limits_hide_all_when_over(self) -> None: + rows = [[0.0, 5.0, 1.0]] * 4 + self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=(3, 2)).shape[0], 0) + + def test_legacy_none_limits_skips_count_rule(self) -> None: + rows = [[0.0, 5.0, 1.0]] * 4 + self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=None).shape[0], 4) + + +# --------------------------------------------------------------------------- # +# B-scan transforms +# --------------------------------------------------------------------------- # +class BscanTransformTest(unittest.TestCase): + def test_mean_ascan_subtraction(self) -> None: + history = deque([np.array([1.0, 1.0], dtype=np.float32), np.array([3.0, 3.0], dtype=np.float32)]) + self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=False), + np.array([[1, 1], [3, 3]], dtype=np.float32))) + self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=True), + np.array([[-1, -1], [1, 1]], dtype=np.float32))) + + def test_levels_abs_mode(self) -> None: + self.assertEqual(bscan_levels(np.array([[1.0, 4.0], [2.0, 3.0]], dtype=np.float32), "abs"), (1.0, 4.0)) + + def test_levels_abs_degenerate(self) -> None: + low, high = bscan_levels(np.full((2, 2), 5.0, dtype=np.float32), "abs") + self.assertEqual(low, 5.0) + self.assertGreater(high, low) + + def test_levels_signed_mode_symmetric(self) -> None: + self.assertEqual(bscan_levels(np.array([[-3.0, 1.0]], dtype=np.float32), "real"), (-3.0, 3.0)) + + def test_build_lut_shape_and_endpoints(self) -> None: + lut = build_lut(["#000000", "#ffffff"]) + self.assertEqual(lut.shape, (256, 3)) + self.assertEqual(lut.dtype, np.uint8) + self.assertTrue(np.array_equal(lut[0], [0, 0, 0])) + self.assertTrue(np.array_equal(lut[-1], [255, 255, 255])) + + def test_lookup_table_for_each_axis_mode(self) -> None: + for mode in ("abs", "real", "phase"): + self.assertEqual(bscan_lookup_table(mode).shape, (256, 3)) + + +class RebuildBscanHistoryTest(unittest.TestCase): + @staticmethod + def _bscan_collection(cid: int, combo, depth, amps, *, name="bscan", kind=1) -> ResultCollection: + trace = np.asarray(amps, dtype=np.float32).astype(np.complex64) + payload = ResultPayload(processing_name=name, kind=kind, + frequency_hz=np.asarray(depth, dtype=np.float32), trace=trace) + block = ResultBlock(combo=ComboKey(input=combo[0], output=combo[1]), payloads=[payload]) + return ResultCollection(collection_id=cid, monotonic_ns=cid, blocks=[block]) + + def test_accumulates_sweeps_per_combo(self) -> None: + history = [ + self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]), + self._bscan_collection(2, (0, 0), [1.0, 2.0], [11.0, 21.0]), + ] + by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0) + self.assertEqual(len(by_combo[(0, 0)]), 2) + self.assertTrue(np.array_equal(axes[(0, 0)], np.array([1.0, 2.0], dtype=np.float32))) + + def test_skips_non_bscan_and_mismatched_payloads(self) -> None: + history = [ + self._bscan_collection(1, (0, 0), [1.0, 2.0], [1.0, 2.0], name="other"), # wrong name + self._bscan_collection(2, (0, 0), [1.0, 2.0], [1.0, 2.0], kind=2), # wrong kind + self._bscan_collection(3, (0, 0), [1.0, 2.0, 3.0], [1.0, 2.0]), # size mismatch + ] + by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0) + self.assertEqual(by_combo, {}) + + def test_depth_axis_change_resets_history(self) -> None: + history = [ + self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]), + self._bscan_collection(2, (0, 0), [1.0, 2.0, 3.0], [11.0, 21.0, 31.0]), # new depth axis + ] + by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0) + self.assertEqual(len(by_combo[(0, 0)]), 1) # reset on axis change; only the latest sweep remains + self.assertEqual(axes[(0, 0)].shape, (3,)) + + def test_floor_collection_id_excludes_older(self) -> None: + history = [ + self._bscan_collection(1, (0, 0), [1.0], [10.0]), + self._bscan_collection(2, (0, 0), [1.0], [20.0]), + ] + by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=1) + self.assertEqual(len(by_combo[(0, 0)]), 1) # only collection_id > 1 + + +# --------------------------------------------------------------------------- # +# GPR display-range helpers (pure static math on the mixin) +# --------------------------------------------------------------------------- # +class GprDisplayHelperTest(unittest.TestCase): + def test_normalized_range_orders_and_expands(self) -> None: + self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(2.0, 5.0), (2.0, 5.0)) + self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(5.0, 2.0), (2.0, 5.0)) # reordered + low, high = AppWindowGprPlotMixin._normalized_display_range(3.0, 3.0) # zero span expands to 0.1 + self.assertAlmostEqual(high - low, 0.1) + self.assertAlmostEqual(0.5 * (low + high), 3.0) + + def test_display_y_min_keeps_surface_margin(self) -> None: + self.assertEqual(AppWindowGprPlotMixin._gpr_display_y_min(2.0, 5.0), 2.0) # surface not visible + self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(0.0, 10.0), -0.3) # 3% of span + self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(-1.0, 1.0), -1.06) # min 0.06 margin + + def test_object_label_candidates_are_distinct_positions(self) -> None: + candidates = AppWindowGprPlotMixin._gpr_object_label_candidates(0.0, 0.0, x_span=1.0, z_span=1.0) + self.assertEqual(len(candidates), 10) + self.assertTrue(all(math.isfinite(x) and math.isfinite(z) for x, z, _ in candidates)) + + +# --------------------------------------------------------------------------- # +# ProcessingLiveConfig normalization +# --------------------------------------------------------------------------- # +class ProcessingLiveConfigTest(unittest.TestCase): + def test_none_positions_become_empty_lists(self) -> None: + cfg = ProcessingLiveConfig(gpr_input_positions=None, gpr_output_positions=None) + self.assertEqual(cfg.gpr_input_positions, []) + self.assertEqual(cfg.gpr_output_positions, []) + + def test_positions_coerced_to_ints(self) -> None: + cfg = ProcessingLiveConfig(gpr_input_positions=[1.5, 2.9], gpr_output_positions=[0.0]) + self.assertEqual(cfg.gpr_input_positions, [1, 2]) + self.assertEqual(cfg.gpr_output_positions, [0]) + + def test_to_dict_is_json_typed(self) -> None: + data = ProcessingLiveConfig().to_dict() + self.assertIsInstance(data["processor_mode"], str) + self.assertIsInstance(data["gpr_input_positions"], list) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/tests/test_run_config_codec.py b/python_app/tests/test_run_config_codec.py new file mode 100644 index 0000000..e8ba92e --- /dev/null +++ b/python_app/tests/test_run_config_codec.py @@ -0,0 +1,232 @@ +"""Run-config decode/validation tests. + +Pins the agreed semantics (not just current behaviour): + * every config integer must be a genuine JSON int — "5", 5.0, true are errors; + an explicit JSON null means "use the default"; + * a sweep is a real range — stop_hz must be strictly greater than start_hz, and + points a positive integer; + * combos must index real switch positions and contain no duplicate input:output; + * ring/gpr structural bounds are enforced in Python (so a bad config fails here, + not in the C++ pipeline at boot). +""" + +from __future__ import annotations + +import unittest + +from python_app.models.run_config_schema import ( + ComboModel, + GprModel, + GprRxGeometryModel, + GprTxGeometryModel, + RadarSweepModel, + RingEndpointModel, + RunConfigModel, +) +from python_app.models.run_config_validation import ( + parse_combos_from_text, + validate_combos, + validate_gpr_model, + validate_ring_endpoint, + validate_sweep_model, +) + + +def _valid_payload() -> dict: + """A canonical, valid run-config payload (the schema defaults serialized).""" + return RunConfigModel().to_dict() + + +class StrictNumericTypingTest(unittest.TestCase): + """Every config integer reads strictly; null falls back to the default.""" + + def _reject_int(self, section: list[str], key: str, value: object) -> None: + payload = _valid_payload() + node = payload + for part in section: + node = node[part] + node[key] = value + with self.assertRaises(ValueError): + RunConfigModel.from_dict(payload) + + def test_codec_int_rejects_string_float_bool(self) -> None: + # radar.sweep.points is read by the codec's strict _read_int. + for value in ("201", 201.0, True): + with self.subTest(value=value): + self._reject_int(["radar", "sweep"], "points", value) + + def test_validation_int_rejects_string_float_bool(self) -> None: + # switch positions are read by run_config_validation._require_int. + for value in ("4", 4.0, True): + with self.subTest(value=value): + self._reject_int(["switches", "port1"], "positions", value) + + def test_genuine_int_accepted(self) -> None: + payload = _valid_payload() + payload["radar"]["sweep"]["points"] = 256 + self.assertEqual(RunConfigModel.from_dict(payload).radar.sweep.points, 256) + + def test_null_uses_default(self) -> None: + payload = _valid_payload() + payload["radar"]["sweep"]["points"] = None + self.assertEqual( + RunConfigModel.from_dict(payload).radar.sweep.points, + RunConfigModel().radar.sweep.points, + ) + + +class StructuralTypingTest(unittest.TestCase): + """A present-but-wrong-shaped section fails loudly instead of being dropped.""" + + def test_combos_must_be_an_array(self) -> None: + payload = _valid_payload() + payload["run"]["combos"] = {"input": 0, "output": 0} + with self.assertRaisesRegex(ValueError, "run.combos must be a JSON array"): + RunConfigModel.from_dict(payload) + + +class SweepValidationTest(unittest.TestCase): + def test_points_must_be_positive(self) -> None: + for points in (0, -1): + with self.subTest(points=points), self.assertRaisesRegex(ValueError, "points"): + validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=points)) + + def test_stop_must_be_strictly_greater_than_start(self) -> None: + with self.assertRaisesRegex(ValueError, "stop_hz"): # equal is not a range + validate_sweep_model(RadarSweepModel(start_hz=2.0, stop_hz=2.0, points=1)) + with self.assertRaisesRegex(ValueError, "stop_hz"): # inverted + validate_sweep_model(RadarSweepModel(start_hz=3.0, stop_hz=2.0, points=1)) + + def test_valid_sweep_passes(self) -> None: + validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=201)) + + +class ComboValidationTest(unittest.TestCase): + def test_within_bounds_passes(self) -> None: + validate_combos( + [ComboModel(input=0, output=0), ComboModel(input=3, output=1)], + input_positions=4, + output_positions=2, + ) + + def test_input_out_of_range_rejected(self) -> None: + for inp in (-1, 4): + with self.subTest(input=inp), self.assertRaisesRegex(ValueError, "input"): + validate_combos([ComboModel(input=inp, output=0)], input_positions=4, output_positions=2) + + def test_output_out_of_range_rejected(self) -> None: + for out in (-1, 2): + with self.subTest(output=out), self.assertRaisesRegex(ValueError, "output"): + validate_combos([ComboModel(input=0, output=out)], input_positions=4, output_positions=2) + + def test_duplicate_combo_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "duplicate"): + validate_combos( + [ComboModel(input=0, output=0), ComboModel(input=0, output=0)], + input_positions=4, + output_positions=2, + ) + + def test_out_of_range_combo_rejected_on_config_load(self) -> None: + payload = _valid_payload() + out_of_range = payload["run"]["combos"][0]["output"] + payload["switches"]["port2"]["positions"] + payload["run"]["combos"].append({"input": 0, "output": out_of_range}) + with self.assertRaisesRegex(ValueError, "out of range"): + RunConfigModel.from_dict(payload) + + +class RingValidationTest(unittest.TestCase): + def test_capacity_and_slot_must_be_positive(self) -> None: + with self.assertRaisesRegex(ValueError, "capacity"): + validate_ring_endpoint(RingEndpointModel(name="r", capacity=0, slot_size_bytes=16)) + with self.assertRaisesRegex(ValueError, "slot_size"): + validate_ring_endpoint(RingEndpointModel(name="r", capacity=4, slot_size_bytes=0)) + + def test_slot_size_uint32_limit(self) -> None: + with self.assertRaisesRegex(ValueError, "uint32"): + validate_ring_endpoint(RingEndpointModel(name="r", capacity=1, slot_size_bytes=1 << 32)) + + def test_segment_size_limit(self) -> None: + with self.assertRaisesRegex(ValueError, "maximum ring segment"): + validate_ring_endpoint(RingEndpointModel(name="r", capacity=1 << 40, slot_size_bytes=1024)) + + def test_valid_ring_passes(self) -> None: + validate_ring_endpoint(RingEndpointModel(name="r", capacity=8, slot_size_bytes=4096)) + + +class GprValidationTest(unittest.TestCase): + @staticmethod + def _gpr(**overrides: object) -> GprModel: + base = {"relative_permittivity": 4.0, "tx_geometry": [], "rx_geometry": []} + base.update(overrides) + return GprModel(**base) # type: ignore[arg-type] + + def _validate(self, gpr: GprModel) -> None: + validate_gpr_model(gpr, input_switch_positions=4, output_switch_positions=2) + + def test_permittivity_must_be_positive(self) -> None: + with self.assertRaisesRegex(ValueError, "relative_permittivity"): + self._validate(self._gpr(relative_permittivity=0.0)) + + def test_tx_output_pos_out_of_range(self) -> None: + with self.assertRaisesRegex(ValueError, "output_pos is out of range"): + self._validate(self._gpr(tx_geometry=[GprTxGeometryModel(output_pos=5, x_m=0.0, y_m=0.0, z_m=0.0)])) + + def test_tx_duplicate_output_pos(self) -> None: + with self.assertRaisesRegex(ValueError, "duplicate output_pos"): + self._validate(self._gpr(tx_geometry=[ + GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0), + GprTxGeometryModel(output_pos=0, x_m=1.0, y_m=0.0, z_m=0.0), + ])) + + def test_rx_input_pos_out_of_range(self) -> None: + with self.assertRaisesRegex(ValueError, "input_pos is out of range"): + self._validate(self._gpr(rx_geometry=[GprRxGeometryModel(input_pos=9, x_m=0.0, y_m=0.0, z_m=0.0)])) + + def test_valid_geometry_passes(self) -> None: + self._validate(self._gpr( + tx_geometry=[GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)], + rx_geometry=[GprRxGeometryModel(input_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)], + )) + + +class ParseCombosFromTextTest(unittest.TestCase): + def test_parses_pairs(self) -> None: + combos = parse_combos_from_text("0:0,1:0,3:1") + self.assertEqual([(c.input, c.output) for c in combos], [(0, 0), (1, 0), (3, 1)]) + + def test_blank_text_is_empty_list(self) -> None: + self.assertEqual(parse_combos_from_text(" "), []) + + def test_missing_colon_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "Expected input:output"): + parse_combos_from_text("00") + + def test_empty_side_rejected(self) -> None: + with self.assertRaises(ValueError): + parse_combos_from_text("0:") + with self.assertRaises(ValueError): + parse_combos_from_text(":0") + + def test_non_integer_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "not an integer"): + parse_combos_from_text("x:0") + + +class RoundTripTest(unittest.TestCase): + def test_default_config_round_trips_idempotently(self) -> None: + once = RunConfigModel().to_dict() + twice = RunConfigModel.from_dict(once).to_dict() + self.assertEqual(once, twice) + + def test_set_values_are_preserved(self) -> None: + payload = _valid_payload() + payload["radar"]["sweep"].update(points=401, start_hz=1.0e9, stop_hz=5.0e9) + model = RunConfigModel.from_dict(payload) + self.assertEqual(model.radar.sweep.points, 401) + self.assertEqual(model.radar.sweep.start_hz, 1.0e9) + self.assertEqual(model.radar.sweep.stop_hz, 5.0e9) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/tests/test_shm_ipc.py b/python_app/tests/test_shm_ipc.py new file mode 100644 index 0000000..10f92f2 --- /dev/null +++ b/python_app/tests/test_shm_ipc.py @@ -0,0 +1,247 @@ +"""IPC/SHM tests: encode/decode round-trips, corruption handling, ring semantics. + +Pins the agreed contract: + * encode -> decode is lossless for raw/preprocessed traces and result payloads; + * a corrupt/truncated frame raises a single, catchable ValueError (never a bare + struct.error / UnicodeDecodeError); + * the ring is latest-wins: on overflow the oldest slot is overwritten and a slow + reader keeps the freshest frames; + * peek_latest returns the newest frame without consuming it; + * opening a missing/incompatible ring fails fast. +""" + +from __future__ import annotations + +import struct +import unittest +from contextlib import suppress +from pathlib import Path + +import numpy as np + +from python_app.models.dataset_model import ( + ComboKey, + ResultBlock, + ResultCollection, + ResultPayload, + SweepCollection, + TraceData, +) +from python_app.orchestration.shm.decoder import ( + PREPROC_MAGIC, + RAW_MAGIC, + RESULT_MAGIC, + decode_result_collection, + decode_trace_collection, +) +from python_app.orchestration.shm.ring_reader import ShmRingReader +from python_app.orchestration.shm.ring_writer import ShmRingWriter +from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection + + +def _trace(in_pos: int, out_pos: int, n: int) -> TraceData: + """Build a trace with float32-exact data so round-trips compare exactly.""" + freq = np.arange(n, dtype=np.float32) + 1.0 + s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64) + s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64) + return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21) + + +class TraceCollectionRoundTripTest(unittest.TestCase): + def _assert_round_trips(self, magic: int) -> None: + collection = SweepCollection( + collection_id=7, + monotonic_ns=123, + traces=[_trace(0, 0, 4), _trace(3, 1, 2)], + capture_start_ns=10, + capture_end_ns=20, + ) + decoded = decode_trace_collection(serialize_trace_collection(collection, magic), magic) + self.assertEqual(decoded.collection_id, 7) + self.assertEqual(decoded.monotonic_ns, 123) + self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (10, 20)) + self.assertEqual(len(decoded.traces), 2) + for original, got in zip(collection.traces, decoded.traces): + self.assertEqual((got.combo.input, got.combo.output), (original.combo.input, original.combo.output)) + self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz)) + self.assertTrue(np.array_equal(got.s11, original.s11)) + self.assertTrue(np.array_equal(got.s21, original.s21)) + + def test_raw_round_trips(self) -> None: + self._assert_round_trips(RAW_MAGIC) + + def test_preprocessed_round_trips(self) -> None: + self._assert_round_trips(PREPROC_MAGIC) + + def test_empty_traces_round_trip(self) -> None: + collection = SweepCollection(collection_id=1, monotonic_ns=2, traces=[]) + decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC) + self.assertEqual(decoded.traces, []) + + +class ResultCollectionRoundTripTest(unittest.TestCase): + def test_all_payload_kinds_round_trip(self) -> None: + image = np.arange(6, dtype=np.float32).reshape((2, 3)) + table = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], dtype=np.float32) + collection = ResultCollection( + collection_id=9, + monotonic_ns=42, + processing_duration_ns=1000, + collection_payloads=[ + ResultPayload( + processing_name="gpr_accumulator", kind=3, + image_x_axis=np.array([0.0, 1.0, 2.0], dtype=np.float32), + image_y_axis=np.array([0.0, 1.0], dtype=np.float32), + image=image, + ), + ResultPayload(processing_name="gpr_points", kind=4, table=table), + ], + blocks=[ + ResultBlock(combo=ComboKey(input=1, output=0), payloads=[ + ResultPayload( + processing_name="bscan", kind=1, + frequency_hz=np.array([1.0, 2.0], dtype=np.float32), + trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64), + ), + ResultPayload(processing_name="snr", kind=2, scalar_value=2.5), + ]), + ], + ) + decoded = decode_result_collection(serialize_result_collection(collection)) + self.assertEqual((decoded.collection_id, decoded.monotonic_ns, decoded.processing_duration_ns), (9, 42, 1000)) + acc, points = decoded.collection_payloads + self.assertEqual(acc.processing_name, "gpr_accumulator") + self.assertTrue(np.array_equal(acc.image, image)) + self.assertTrue(np.array_equal(points.table, table)) + block = decoded.blocks[0] + self.assertEqual((block.combo.input, block.combo.output), (1, 0)) + self.assertTrue(np.array_equal(block.payloads[0].trace, np.array([1 + 1j, 2 - 2j], dtype=np.complex64))) + self.assertAlmostEqual(block.payloads[1].scalar_value, 2.5) + + +class CorruptFrameTest(unittest.TestCase): + """Any corruption surfaces as ValueError (the single catchable contract).""" + + def _valid_trace_bytes(self) -> bytes: + return serialize_trace_collection( + SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(0, 0, 3)]), RAW_MAGIC + ) + + def test_bad_magic(self) -> None: + corrupt = b"\x00\x00\x00\x00" + self._valid_trace_bytes()[4:] + with self.assertRaises(ValueError): + decode_trace_collection(corrupt, RAW_MAGIC) + + def test_truncated_buffer(self) -> None: + with self.assertRaises(ValueError): + decode_trace_collection(self._valid_trace_bytes()[:18], RAW_MAGIC) + + def test_absurd_trace_count(self) -> None: + # Claims 1e6 traces but supplies none -> the first trace read runs off the end. + corrupt = struct.pack(" None: + corrupt = struct.pack(" None: + corrupt = struct.pack(" str: + return f"/radar_test_{self._testMethodName}{suffix}" + + def _writer(self, name: str, capacity: int, slot_size: int) -> ShmRingWriter: + with suppress(OSError): + (Path("/dev/shm") / name[1:]).unlink() # drop a leftover from a crashed run + writer = ShmRingWriter(name, capacity, slot_size) + self.addCleanup(self._cleanup, name, writer) + return writer + + def _reader(self, name: str, **kw: object) -> ShmRingReader: + reader = ShmRingReader(name, **kw) + self.addCleanup(self._safe_close, reader) + return reader + + @staticmethod + def _safe_close(obj: object) -> None: + with suppress(Exception): + obj.close() # type: ignore[attr-defined] + + @staticmethod + def _cleanup(name: str, writer: ShmRingWriter) -> None: + with suppress(Exception): + writer.close() + with suppress(OSError): + (Path("/dev/shm") / name[1:]).unlink() + + +class RingRoundTripTest(_RingTestCase): + def test_fifo_round_trip(self) -> None: + name = self._ring_name() + writer = self._writer(name, capacity=8, slot_size=64) + reader = self._reader(name) + payloads = [f"frame{i}".encode() for i in range(5)] + for p in payloads: + self.assertTrue(writer.push(p)) + self.assertEqual([reader.pop_payload() for _ in payloads], payloads) + self.assertIsNone(reader.pop_payload()) # empty afterwards + + def test_oversized_payload_rejected(self) -> None: + writer = self._writer(self._ring_name(), capacity=4, slot_size=8) + self.assertFalse(writer.push(b"x" * 9)) # larger than the slot + + +class RingOverflowTest(_RingTestCase): + def test_latest_wins_drops_oldest(self) -> None: + name = self._ring_name() + writer = self._writer(name, capacity=4, slot_size=64) + reader = self._reader(name) + for i in range(7): # 3 more than capacity + self.assertTrue(writer.push(f"f{i}".encode())) + # The 4 newest survive; the 3 oldest were overwritten. + survivors = [] + while (item := reader.pop_payload()) is not None: + survivors.append(item) + self.assertEqual(survivors, [b"f3", b"f4", b"f5", b"f6"]) + + +class PeekLatestTest(_RingTestCase): + def test_peek_is_latest_and_non_consuming(self) -> None: + name = self._ring_name() + writer = self._writer(name, capacity=8, slot_size=64) + reader = self._reader(name) + self.assertIsNone(reader.peek_latest_payload()) # nothing published yet + for i in range(3): + writer.push(f"f{i}".encode()) + self.assertEqual(reader.peek_latest_payload(), b"f2") # newest + self.assertEqual(reader.peek_latest_payload(), b"f2") # stable, not consumed + self.assertEqual(reader.pop_payload(), b"f0") # consumer cursor untouched + writer.push(b"f3") + self.assertEqual(reader.peek_latest_payload(), b"f3") # follows the newest + + +class RingOpenTest(_RingTestCase): + def test_missing_ring_fails_fast(self) -> None: + with self.assertRaises(FileNotFoundError): + ShmRingReader("/radar_test_definitely_missing", open_timeout_s=0.1, open_poll_s=0.02) + + def test_incompatible_header_rejected(self) -> None: + name = "/radar_test_bad_header" + path = Path("/dev/shm") / name[1:] + path.write_bytes(b"\x00" * 128) # right size, wrong magic + self.addCleanup(lambda: path.unlink(missing_ok=True)) + with self.assertRaises(RuntimeError): + ShmRingReader(name) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/tests/test_storage_webui.py b/python_app/tests/test_storage_webui.py new file mode 100644 index 0000000..5a1857b --- /dev/null +++ b/python_app/tests/test_storage_webui.py @@ -0,0 +1,212 @@ +"""Storage + embedded WebUI tests. + +Pins the agreed semantics: + * NPZ set persistence round-trips traces + metadata (float32 wire precision); + * vna_history export fails loud (ValueError) when no matching traces / bad stage; + * the web bridge rejects unknown live-settings fields (HTTP 400), serves immutable + snapshot copies, and forwards controls as Qt signals; + * the web fan-out is latest-wins — a full client queue drops its oldest frame. +""" + +from __future__ import annotations + +import asyncio +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import numpy as np + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # before any Qt import +from PyQt6.QtWidgets import QApplication # noqa: E402 + +from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402 + AppWindowWebController, + AppWindowWebMixin, +) +from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402 +from python_app.storage.npz.store import NpzStore # noqa: E402 +from python_app.storage.npz.vna_history_json import ( # noqa: E402 + _complex_to_points, + _normalize_channel, + build_vna_history_payload, +) +from python_app.webui.streaming import RingBroadcaster # noqa: E402 + + +def setUpModule() -> None: + global _app + _app = QApplication.instance() or QApplication([]) + + +def _trace(in_pos: int, out_pos: int) -> TraceData: + return TraceData( + combo=ComboKey(input=in_pos, output=out_pos), + frequency_hz=np.array([1.0, 2.0], dtype=np.float32), + s11=np.array([1 + 1j, 2 + 2j], dtype=np.complex64), + s21=np.array([3 + 3j, 4 - 4j], dtype=np.complex64), + ) + + +# --------------------------------------------------------------------------- # +# NPZ set persistence +# --------------------------------------------------------------------------- # +class NpzStoreRoundTripTest(unittest.TestCase): + def setUp(self) -> None: + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.store = NpzStore(Path(self._dir.name)) + + def test_save_then_load_round_trips_traces_and_metadata(self) -> None: + col = SweepCollection(collection_id=7, monotonic_ns=11, traces=[_trace(0, 0), _trace(1, 0)], + capture_start_ns=100, capture_end_ns=200) + self.store.save_set("calibration", "radar1", "set1", col) + loaded = self.store.load_set("calibration", "radar1", "set1") + + self.assertEqual(loaded.collection_id, 7) + self.assertEqual((loaded.capture_start_ns, loaded.capture_end_ns), (100, 200)) + self.assertEqual(len(loaded.traces), 2) + by_combo = {(t.combo.input, t.combo.output): t for t in loaded.traces} + self.assertTrue(np.array_equal(by_combo[(0, 0)].s21, _trace(0, 0).s21)) + self.assertTrue(np.array_equal(by_combo[(1, 0)].frequency_hz, _trace(1, 0).frequency_hz)) + + def test_set_appears_in_listing_and_leaves_no_tmp(self) -> None: + self.store.save_set("calibration", "radar1", "set1", SweepCollection(collection_id=1, monotonic_ns=1, + traces=[_trace(0, 0)])) + self.assertIn("set1", self.store.list_sets("calibration", "radar1")) + leftover = list(Path(self._dir.name).rglob("*.tmp")) + self.assertEqual(leftover, []) + + def test_load_missing_set_raises(self) -> None: + with self.assertRaises(Exception): + self.store.load_set("calibration", "radar1", "absent") + + +# --------------------------------------------------------------------------- # +# vna_history export +# --------------------------------------------------------------------------- # +class VnaHistoryTest(unittest.TestCase): + def _sweeps(self, *combos) -> list[SweepCollection]: + return [SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(i, o) for i, o in combos])] + + def test_builds_payload_for_matching_combo(self) -> None: + payload = build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0) + self.assertEqual(payload["input_index"], 0) + self.assertEqual(payload["channel"], "s21") + self.assertEqual(payload["preprocessed_record_count"], 1) + self.assertTrue(payload["sweep_history"]) + + def test_no_matching_traces_raises(self) -> None: + with self.assertRaises(ValueError): + build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=9, output_index=9) + + def test_invalid_primary_stage_raises(self) -> None: + with self.assertRaisesRegex(ValueError, "primary_stage"): + build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0, primary_stage="x") + + def test_normalize_channel(self) -> None: + self.assertEqual(_normalize_channel("S21"), "s21") + self.assertEqual(_normalize_channel(" s11 "), "s11") + with self.assertRaises(ValueError): + _normalize_channel("s99") + + def test_complex_to_points(self) -> None: + self.assertEqual(_complex_to_points(np.array([1 + 2j, 3 - 4j])), [[1.0, 2.0], [3.0, -4.0]]) + + +# --------------------------------------------------------------------------- # +# Web bridge controller +# --------------------------------------------------------------------------- # +class WebControllerTest(unittest.TestCase): + def setUp(self) -> None: + self.controller = AppWindowWebController() + self.addCleanup(self.controller.deleteLater) + + def test_rejects_unknown_field(self) -> None: + with self.assertRaisesRegex(ValueError, "Unknown live-settings"): + self.controller.apply_live_settings({"definitely_not_a_field": 1}) + + def test_known_field_emits_and_returns_snapshot(self) -> None: + received: list[dict] = [] + self.controller.apply_settings_requested.connect(received.append) + out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5}) + self.assertEqual(received, [{"gpr_min_visible_score": 0.5}]) + self.assertIsInstance(out, list) + + def test_snapshot_is_replaced_and_returned_as_copy(self) -> None: + self.controller.update_snapshot(status={"running": True}, live_settings=[{"name": "x"}], frame={"seq": 1}) + status = self.controller.status() + self.assertEqual(status, {"running": True}) + status["running"] = False # mutating the copy must not affect the controller + self.assertTrue(self.controller.status()["running"]) + self.assertEqual(self.controller.peek_frame(), {"seq": 1}) + + def test_frame_only_updates_when_present(self) -> None: + self.controller.update_snapshot(status={}, live_settings=[], frame={"seq": 1}) + self.controller.update_snapshot(status={}, live_settings=[], frame=None) # no new grab + self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame + + def test_controls_emit_signals(self) -> None: + fired: list[str] = [] + self.controller.start_requested.connect(lambda: fired.append("start")) + self.controller.stop_requested.connect(lambda: fired.append("stop")) + self.controller.single_capture_requested.connect(lambda: fired.append("single")) + self.controller.capture_requested.connect(lambda: fired.append("capture")) + self.controller.start() + self.controller.stop() + self.controller.single_capture() + self.controller.capture_tmp_reference() + self.assertEqual(fired, ["start", "stop", "single", "capture"]) + + +class WebPortTest(unittest.TestCase): + def _port_with_env(self, value: str | None) -> int: + env = {} if value is None else {"RADAR_SYSTEM_WEBUI_PORT": value} + with mock.patch.dict(os.environ, env, clear=False): + if value is None: + os.environ.pop("RADAR_SYSTEM_WEBUI_PORT", None) + return AppWindowWebMixin._web_ui_port() + + def test_default_when_unset_or_invalid(self) -> None: + self.assertEqual(self._port_with_env(None), 8080) + self.assertEqual(self._port_with_env("abc"), 8080) + self.assertEqual(self._port_with_env("99999"), 8080) # out of range + self.assertEqual(self._port_with_env("0"), 8080) + + def test_valid_port(self) -> None: + self.assertEqual(self._port_with_env("9000"), 9000) + + +# --------------------------------------------------------------------------- # +# Latest-wins fan-out +# --------------------------------------------------------------------------- # +class RingBroadcasterFanOutTest(unittest.TestCase): + def test_full_client_queue_drops_oldest(self) -> None: + async def scenario() -> None: + broadcaster = RingBroadcaster(controller=object()) + queue: asyncio.Queue = asyncio.Queue(maxsize=1) + broadcaster.register(queue) + broadcaster._publish({"type": "frame", "seq": 1}) + broadcaster._publish({"type": "frame", "seq": 2}) # evicts seq 1 + self.assertEqual(queue.qsize(), 1) + self.assertEqual(queue.get_nowait(), {"type": "frame", "seq": 2}) # newest survives + + asyncio.run(scenario()) + + def test_unregister_stops_delivery(self) -> None: + async def scenario() -> None: + broadcaster = RingBroadcaster(controller=object()) + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + broadcaster.register(queue) + broadcaster.unregister(queue) + broadcaster.unregister(queue) # idempotent + broadcaster._publish({"type": "frame", "seq": 1}) + self.assertEqual(queue.qsize(), 0) + + asyncio.run(scenario()) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/webui/__init__.py b/python_app/webui/__init__.py new file mode 100644 index 0000000..12832ad --- /dev/null +++ b/python_app/webui/__init__.py @@ -0,0 +1,15 @@ +"""Isolated, Qt-free web frontend for the radar_system Pi appliance. + +The web UI streams the live GPR view and exposes the pipeline controls of the +desktop app without any Qt dependency. It depends only on the small +:class:`~python_app.webui.controller.WebController` contract; the embedded Qt +bridge (``gui/controllers/app_window_web_mixin.py``) implements that contract by +forwarding to the AppWindow's existing buttons, so no control flow is duplicated. + +- :mod:`controller` — the Qt-free control/read contract. +- :mod:`streaming` — latest-wins fan-out of plot frames/status/settings to clients. +- :mod:`routes` / :mod:`app` — FastAPI surface and application factory. +- :mod:`server` — run the app on a background thread inside the host process. +""" + +from __future__ import annotations diff --git a/python_app/webui/app.py b/python_app/webui/app.py new file mode 100644 index 0000000..815050e --- /dev/null +++ b/python_app/webui/app.py @@ -0,0 +1,43 @@ +"""FastAPI application factory for the embedded radar web UI. + +The controller (the Qt bridge that forwards to the AppWindow) is created and +owned by the host process and injected here. The app's only owned resource is the +:class:`RingBroadcaster` polling task, created and torn down by the lifespan. The +static single-page frontend is mounted at ``/`` and the JSON/WS API under +``/api`` and ``/ws``. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from python_app.webui.controller import WebController +from python_app.webui.routes import router +from python_app.webui.streaming import RingBroadcaster + +_STATIC_DIR = Path(__file__).resolve().parent / "static" + + +def create_app(controller: WebController) -> FastAPI: + """Build the FastAPI app that serves and streams for ``controller``.""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + broadcaster = RingBroadcaster(controller) + app.state.controller = controller + app.state.broadcaster = broadcaster + broadcaster.start() + try: + yield + finally: + await broadcaster.stop() + + app = FastAPI(title="Radar Web UI", lifespan=lifespan) + app.include_router(router) + # Mount the SPA last so the API routes above always take precedence. + app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="static") + return app diff --git a/python_app/webui/controller.py b/python_app/webui/controller.py new file mode 100644 index 0000000..d346e6a --- /dev/null +++ b/python_app/webui/controller.py @@ -0,0 +1,42 @@ +"""The Qt-free contract the web layer depends on. + +The web layer (``app``/``routes``/``streaming``) is deliberately free of any Qt +or hardware knowledge: it talks only to a :class:`WebController`. The embedded +bridge in ``gui/controllers/app_window_web_mixin.py`` implements this protocol by +forwarding control actions to the AppWindow's *existing* buttons and exposing +read-only snapshots — so the very same desktop logic backs the browser, with no +duplicated control flow. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class WebController(Protocol): + """Control + read surface the web layer needs; implemented by the Qt bridge.""" + + def start(self) -> None: + """Start a continuous run (the desktop "Start" button).""" + + def single_capture(self) -> None: + """Run a single-capture acquisition (the desktop "Single Capture" button).""" + + def stop(self) -> None: + """Stop the running pipeline (the desktop "Stop" button).""" + + def capture_tmp_reference(self) -> None: + """Capture and select a temporary reference (the desktop button).""" + + def apply_live_settings(self, fields: dict) -> list: + """Apply live processor settings; returns the current settings schema.""" + + def current_live_settings(self) -> list: + """Return the live-settings schema (built from the Qt widgets).""" + + def status(self) -> dict: + """Return a snapshot of pipeline/run state.""" + + def peek_frame(self) -> dict | None: + """Return the latest rendered-plot frame (PNG of the Qt plot), or ``None``.""" diff --git a/python_app/webui/routes.py b/python_app/webui/routes.py new file mode 100644 index 0000000..715fded --- /dev/null +++ b/python_app/webui/routes.py @@ -0,0 +1,87 @@ +"""HTTP and WebSocket routes for the embedded radar web UI. + +Every handler is a thin shell over the :class:`WebController` (which forwards to +the AppWindow's existing buttons) and the :class:`RingBroadcaster` (the single +frame source). There is no ownership gating: the web UI lives inside the process +that already owns the hardware, so its controls are simply that process's buttons. +""" + +from __future__ import annotations + +import asyncio +import contextlib + +from fastapi import APIRouter, Body, HTTPException, Request, WebSocket, WebSocketDisconnect + +from python_app.webui.controller import WebController +from python_app.webui.streaming import RingBroadcaster + +router = APIRouter() + + +def _controller(request: Request) -> WebController: + return request.app.state.controller + + +@router.get("/api/status") +async def get_status(request: Request) -> dict: + return _controller(request).status() + + +@router.post("/api/start") +async def post_start(request: Request) -> dict: + controller = _controller(request) + controller.start() + return controller.status() + + +@router.post("/api/single_capture") +async def post_single_capture(request: Request) -> dict: + controller = _controller(request) + controller.single_capture() + return controller.status() + + +@router.post("/api/stop") +async def post_stop(request: Request) -> dict: + controller = _controller(request) + controller.stop() + return controller.status() + + +@router.post("/api/tmp_reference") +async def post_tmp_reference(request: Request) -> dict: + controller = _controller(request) + controller.capture_tmp_reference() + return controller.status() + + +@router.get("/api/live_settings") +async def get_live_settings(request: Request) -> list: + return _controller(request).current_live_settings() + + +@router.post("/api/live_settings") +async def post_live_settings(request: Request, fields: dict = Body(default={})) -> list: + try: + return _controller(request).apply_live_settings(fields) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.websocket("/ws") +async def ws(websocket: WebSocket) -> None: + """Stream frames and status to one client until it disconnects.""" + await websocket.accept() + broadcaster: RingBroadcaster = websocket.app.state.broadcaster + queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1) + broadcaster.register(queue) + try: + while True: + await websocket.send_json(await queue.get()) + except WebSocketDisconnect: + pass + finally: + broadcaster.unregister(queue) + with contextlib.suppress(Exception): + await websocket.close() diff --git a/python_app/webui/server.py b/python_app/webui/server.py new file mode 100644 index 0000000..0728753 --- /dev/null +++ b/python_app/webui/server.py @@ -0,0 +1,53 @@ +"""Run the web UI's FastAPI app on a background thread. + +The radar app already owns the hardware and the Qt event loop, so the web server +lives in a daemon thread inside that process (uvicorn brings its own asyncio loop +for the thread). Control requests hop back to the Qt main thread via the bridge's +queued signals — the web thread never touches Qt directly. +""" + +from __future__ import annotations + +import logging +import threading + +import uvicorn + +from python_app.webui.app import create_app +from python_app.webui.controller import WebController + +logger = logging.getLogger(__name__) + + +class WebUiServer: + """Owns a uvicorn server bound to a controller, run on a daemon thread.""" + + def __init__(self, controller: WebController, *, host: str = "0.0.0.0", port: int = 8080) -> None: + """Build the server for ``controller`` (not started until :meth:`start`).""" + config = uvicorn.Config(create_app(controller), host=host, port=port, log_level="warning") + self._server = uvicorn.Server(config) + self._thread = threading.Thread(target=self._serve, name="radar-webui", daemon=True) + + def start(self) -> None: + """Start serving on the background thread.""" + self._thread.start() + + def is_alive(self) -> bool: + """Return whether the server thread is still running.""" + return self._thread.is_alive() + + def _serve(self) -> None: + """Run uvicorn, surfacing a startup/runtime failure instead of dying silently. + + The bind happens on this thread after ``start()`` has already returned, so a + failure (e.g. the port is taken) would otherwise be invisible. + """ + try: + self._server.run() + except Exception: # noqa: BLE001 - log, never crash the host process + logger.exception("Web UI server thread exited with an error") + + def stop(self) -> None: + """Ask uvicorn to exit and wait briefly for the thread to unwind.""" + self._server.should_exit = True + self._thread.join(timeout=5.0) diff --git a/python_app/webui/static/app.js b/python_app/webui/static/app.js new file mode 100644 index 0000000..751b98b --- /dev/null +++ b/python_app/webui/static/app.js @@ -0,0 +1,298 @@ +"use strict"; + +/* ------------------------------------------------------------------ * + * Radar System web client. + * Streams the live Qt plot as an image (latest-wins) + REST controls + + * the processor live-settings panel, synced both ways with the desktop. + * ------------------------------------------------------------------ */ + +/* ---- DOM handles ------------------------------------------------- */ +const plotImg = document.getElementById("plot"); + +const btnStart = document.getElementById("btn-start"); +const btnSingle = document.getElementById("btn-single"); +const btnStop = document.getElementById("btn-stop"); +const btnTmpRef = document.getElementById("btn-tmp-ref"); +const btnApply = document.getElementById("btn-apply"); +const btnResetHistory = document.getElementById("btn-reset-history"); + +const settingsToggle = document.getElementById("settings-toggle"); +const sidePanel = document.querySelector(".side-panel"); +const settingsFields = document.getElementById("settings-fields"); +const settingsNote = document.getElementById("settings-note"); + +const runningEl = document.getElementById("stat-running"); +const processorEl = document.getElementById("stat-processor"); +const ringEl = document.getElementById("stat-ring"); +const staleEl = document.getElementById("stat-stale"); +const toastEl = document.getElementById("toast"); + +/* ---- state ------------------------------------------------------- */ +let pendingPng = null; // newest PNG (base64), shown on the next animation frame +let lastFrameTs = 0; // performance.now() of the last received frame + +/* ---- helpers ----------------------------------------------------- */ +function toast(message, isError) { + toastEl.textContent = message; + toastEl.classList.toggle("error", !!isError); + toastEl.classList.add("show"); + clearTimeout(toast._t); + toast._t = setTimeout(() => toastEl.classList.remove("show"), 2600); +} + +async function api(path, body) { + const opts = { method: body === undefined ? "GET" : "POST" }; + if (body !== undefined) { + opts.headers = { "Content-Type": "application/json" }; + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + let data = null; + try { data = await res.json(); } catch (_) { /* empty body */ } + if (!res.ok) { + const detail = (data && data.detail) || `HTTP ${res.status}`; + throw new Error(detail); + } + return data; +} + +/* ---- controls ---------------------------------------------------- */ +function bindControl(button, path, body) { + button.addEventListener("click", async () => { + button.disabled = true; + try { + await api(path, body); + } catch (err) { + toast(err.message, true); + } finally { + button.disabled = false; + } + }); +} +bindControl(btnStart, "/api/start", {}); +bindControl(btnSingle, "/api/single_capture", {}); +bindControl(btnStop, "/api/stop", {}); +bindControl(btnTmpRef, "/api/tmp_reference", {}); + +/* ---- settings panel --------------------------------------------- */ +settingsToggle.addEventListener("click", () => sidePanel.classList.toggle("collapsed")); + +// The form is built ENTIRELY from the schema the server derives from the Qt widgets +// (field, group, kind, options, ranges, value, enabled). The web hardcodes nothing +// and shows only the active mode's fields, so it always mirrors the desktop. +const fieldInputs = {}; // field name -> { el, kind, dirty } +let formSignature = ""; // field names currently in the form (detect mode/structure change) + +function makeFieldRow(entry) { + const row = document.createElement("div"); + row.className = "field"; + const label = document.createElement("label"); + label.textContent = entry.name; + label.htmlFor = "f_" + entry.name; + row.appendChild(label); + + let el; + if (entry.kind === "bool") { + el = document.createElement("input"); + el.type = "checkbox"; + el.checked = !!entry.value; + } else if (entry.kind === "select") { + el = document.createElement("select"); + for (const opt of entry.options || []) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + el.appendChild(o); + } + el.value = String(entry.value); + } else if (entry.kind === "int" || entry.kind === "float") { + el = document.createElement("input"); + el.type = "number"; + if (entry.min != null) el.min = entry.min; + if (entry.max != null) el.max = entry.max; + el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1); + el.value = entry.value; + } else { + el = document.createElement("input"); + el.type = "text"; + el.value = entry.value == null ? "" : String(entry.value); + } + el.id = "f_" + entry.name; + if (entry.enabled === false) el.disabled = true; + + // Mark dirty while edited so a live push never overwrites a half-entered value. + const markDirty = () => { fieldInputs[entry.name].dirty = true; }; + el.addEventListener("input", markDirty); + el.addEventListener("change", markDirty); + // Switching mode changes which settings are shown — apply it immediately. + if (entry.name === "processor_mode") { + el.addEventListener("change", () => applyOne("processor_mode", el.value)); + } + row.appendChild(el); + fieldInputs[entry.name] = { el, kind: entry.kind, dirty: false }; + return row; +} + +function buildSettingsForm(schema) { + settingsFields.innerHTML = ""; + for (const key in fieldInputs) delete fieldInputs[key]; + let lastGroup = null; + for (const entry of schema) { + if (entry.group !== lastGroup) { + lastGroup = entry.group; + const heading = document.createElement("div"); + heading.className = "group-title"; + heading.textContent = entry.group; + settingsFields.appendChild(heading); + } + settingsFields.appendChild(makeFieldRow(entry)); + } + formSignature = schema.map((e) => e.name).join(","); +} + +function collectFields() { + const out = {}; + for (const key in fieldInputs) { + const { el, kind } = fieldInputs[key]; + if (kind === "bool") out[key] = el.checked; + else if (kind === "int") out[key] = parseInt(el.value, 10); + else if (kind === "float") out[key] = parseFloat(el.value); + else out[key] = el.value; // select + text (positions are sent as CSV text) + } + return out; +} + +function setFieldValue(input, value) { + const { el, kind } = input; + if (kind === "bool") el.checked = !!value; + else if (kind === "select") el.value = String(value); + else el.value = value == null ? "" : value; +} + +function clearDirty() { + for (const key in fieldInputs) fieldInputs[key].dirty = false; +} + +async function applyOne(field, value) { + try { + await api("/api/live_settings", { [field]: value }); + } catch (err) { + toast(err.message, true); + } +} + +// Update the form from a schema pushed by the desktop. Rebuild if the field set +// changed (e.g. the mode switched); otherwise update values in place without +// clobbering a field the operator is editing here. +function applySettings(schema) { + if (schema.map((e) => e.name).join(",") !== formSignature) { + buildSettingsForm(schema); + return; + } + for (const entry of schema) { + const input = fieldInputs[entry.name]; + if (!input || input.el === document.activeElement || input.dirty) continue; + setFieldValue(input, entry.value); + if (entry.enabled !== undefined) input.el.disabled = entry.enabled === false; + } +} + +btnApply.addEventListener("click", async () => { + btnApply.disabled = true; + try { + await api("/api/live_settings", collectFields()); + clearDirty(); // applied; let live pushes update the form again + settingsNote.textContent = "Applied."; + } catch (err) { + settingsNote.textContent = err.message; + toast(err.message, true); + } finally { + btnApply.disabled = false; + } +}); + +btnResetHistory.addEventListener("click", async () => { + btnResetHistory.disabled = true; + try { + await api("/api/live_settings", { history_command: "clear_all" }); + settingsNote.textContent = "History reset requested."; + } catch (err) { + settingsNote.textContent = err.message; + toast(err.message, true); + } finally { + btnResetHistory.disabled = false; + } +}); + +async function loadSettings() { + try { + const cfg = await api("/api/live_settings"); + buildSettingsForm(cfg); + } catch (err) { + settingsNote.textContent = "Could not load settings: " + err.message; + } +} + +/* ---- status ------------------------------------------------------ */ +function setStat(el, label, value, cls) { + el.className = "stat" + (cls ? " " + cls : ""); + el.innerHTML = label + ": "; + el.querySelector("b").textContent = value; +} + +function applyStatus(s) { + if ("running" in s) + setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off"); + if ("processor_running" in s) + setStat(processorEl, "processor", s.processor_running ? "yes" : "no", + s.processor_running ? "ok" : "off"); + if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—", + s.ring_name ? "" : "off"); +} + +/* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */ +function renderLoop() { + if (pendingPng !== null) { + // Show exactly what the desktop draws; the browser scales it to fit (CSS). + plotImg.src = "data:image/png;base64," + pendingPng; + pendingPng = null; + } + // Stale indicator (>2s without a frame). + const stale = performance.now() - lastFrameTs > 2000; + staleEl.classList.toggle("stale", stale && lastFrameTs > 0); + staleEl.textContent = lastFrameTs === 0 ? "no data" : stale ? "stale" : "live"; + requestAnimationFrame(renderLoop); +} + +/* ---- WebSocket --------------------------------------------------- */ +function connectWs() { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/ws`); + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (msg.type === "frame") { + pendingPng = msg.png_b64; // latest-wins; rAF swaps the image + lastFrameTs = performance.now(); + } else if (msg.type === "status") { + applyStatus(msg); + } else if (msg.type === "settings") { + applySettings(msg.schema); // live desktop schema mirrors into the form + } + }; + ws.onclose = () => setTimeout(connectWs, 1500); + ws.onerror = () => ws.close(); +} + +/* ---- boot -------------------------------------------------------- */ +async function init() { + requestAnimationFrame(renderLoop); + try { + applyStatus(await api("/api/status")); + } catch (err) { + settingsNote.textContent = "Status unavailable: " + err.message; + } + await loadSettings(); + connectWs(); +} +init(); diff --git a/python_app/webui/static/index.html b/python_app/webui/static/index.html new file mode 100644 index 0000000..245b9e1 --- /dev/null +++ b/python_app/webui/static/index.html @@ -0,0 +1,57 @@ + + + + + + Radar System + + + +
+
Radar System
+
+ + + + +
+
+ +
+
+
+ Processor output +
+
+ Live processor plot +
+
+ + +
+ +
+ running: — + processor: — + ring: — + live +
+ +
+ + + + diff --git a/python_app/webui/static/style.css b/python_app/webui/static/style.css new file mode 100644 index 0000000..7047342 --- /dev/null +++ b/python_app/webui/static/style.css @@ -0,0 +1,289 @@ +/* Theme mirrors python_app/gui/theme.py (light Fusion palette). */ +:root { + --bg: #f3f6fb; + --panel: #ffffff; + --panel-alt: #fbfdff; + --border: #c9d4e1; + --border-soft: #d7dee8; + --text: #1f2937; + --muted: #6c7b8d; + --status: #35507a; + --accent: #2f7ee6; + --accent-hover: #3b8bf4; + --btn-bg: #f8fafc; + --btn-hover: #eef3f9; + --btn-press: #e4ebf4; + --disabled-text: #98a4b3; + --disabled-bg: #f3f5f8; + --danger: #f94144; + --mono: "DejaVu Sans Mono", ui-monospace, monospace; + --sans: "Segoe UI", system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 13px; +} + +body { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* Top bar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--panel); + border-bottom: 1px solid var(--border-soft); + gap: 12px; +} + +.brand { + font-weight: 700; + font-size: 15px; + color: var(--status); + letter-spacing: 0.2px; +} + +.controls { display: flex; gap: 8px; flex-wrap: wrap; } + +/* Buttons */ +.btn { + background: var(--btn-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 7px 12px; + color: var(--text); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} +.btn:hover:not(:disabled) { background: var(--btn-hover); } +.btn:active:not(:disabled) { background: var(--btn-press); } +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} +.btn.primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn:disabled { + color: var(--disabled-text); + background: var(--disabled-bg); + border-color: var(--border-soft); + cursor: not-allowed; +} + +/* Layout */ +.layout { + flex: 1; + display: flex; + min-height: 0; + padding: 14px; + gap: 14px; +} + +/* Plot */ +.plot-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: 10px; + padding: 10px; +} +.plot-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 8px; +} +.plot-title { font-weight: 600; color: var(--status); } +.axes-label { color: var(--muted); font-family: var(--mono); font-size: 12px; } +.canvas-wrap { + flex: 1; + min-height: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: #0f141c; /* matches the pyqtgraph plot background behind letterboxing */ + overflow: hidden; +} +#plot { display: block; width: 100%; height: 100%; object-fit: contain; } + +/* Side panel */ +.side-panel { + width: 340px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: 10px; + overflow: hidden; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--border-soft); +} +.panel-title { font-weight: 600; color: var(--status); } +.chevron { color: var(--muted); transition: transform 0.15s ease; } +.side-panel.collapsed .chevron { transform: rotate(-90deg); } +.panel-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.side-panel.collapsed .panel-body { display: none; } + +.settings-fields { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px 12px; +} +.group-title { + margin: 12px 0 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); + font-weight: 700; +} +.group-title:first-child { margin-top: 4px; } + +.field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 0; +} +.field label { + flex: 1; + font-size: 12px; + color: var(--text); + word-break: break-word; +} +.field input[type="text"], +.field input[type="number"], +.field select { + width: 140px; + flex-shrink: 0; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 7px; + padding: 4px 7px; + color: var(--text); + font-family: var(--mono); + font-size: 12px; +} +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--accent); +} +.field input[type="checkbox"] { + width: 16px; + height: 16px; + flex-shrink: 0; + accent-color: var(--accent); +} + +.settings-actions { + display: flex; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--border-soft); +} +.settings-actions .btn { flex: 1; } +.note { + padding: 0 12px 10px; + color: var(--muted); + font-size: 11px; + min-height: 14px; +} + +/* Status bar */ +.statusbar { + display: flex; + gap: 18px; + align-items: center; + padding: 7px 16px; + background: var(--panel); + border-top: 1px solid var(--border-soft); + font-family: var(--mono); + font-size: 12px; + color: var(--status); + flex-wrap: wrap; +} +.stat b { color: var(--text); font-weight: 600; } +.stat.ok b { color: #1b7a3d; } +.stat.off b { color: var(--muted); } +#stat-stale { + margin-left: auto; + padding: 2px 9px; + border-radius: 999px; + background: #e4f0e6; + color: #1b7a3d; + font-weight: 600; +} +#stat-stale.stale { + background: #fde2e3; + color: var(--danger); +} + +/* Toast */ +.toast { + position: fixed; + bottom: 56px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--status); + color: #ffffff; + padding: 9px 16px; + border-radius: 8px; + font-size: 13px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + max-width: 70vw; +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.toast.error { background: var(--danger); } + +/* Narrow screens / phones: stack the plot above the settings, full-width controls. */ +@media (max-width: 760px) { + .topbar { flex-wrap: wrap; } + .controls { width: 100%; } + .controls .btn { flex: 1 1 auto; } + .layout { flex-direction: column; padding: 10px; gap: 10px; } + .plot-panel { flex: none; height: 45vh; } + .side-panel { width: auto; flex: 1; min-height: 0; } + .field input[type="text"], + .field input[type="number"], + .field select { width: 130px; } + .statusbar { gap: 12px; } +} diff --git a/python_app/webui/streaming.py b/python_app/webui/streaming.py new file mode 100644 index 0000000..658ccd4 --- /dev/null +++ b/python_app/webui/streaming.py @@ -0,0 +1,105 @@ +"""Fan-out of pipeline result frames and status to connected web clients. + +A single broadcaster task polls the :class:`WebController` off the event loop and +pushes the freshest frame (latest-wins) plus a slower status heartbeat to every +registered client. Each client is a bounded ``asyncio.Queue`` with a drop-oldest +policy, so a slow socket can never stall the producer or the loop — if a client +falls behind it simply skips intermediate frames and always gets the newest. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging + +from python_app.webui.controller import WebController + +logger = logging.getLogger(__name__) + +# Poll the controller this often; the C++ pipeline publishes well below this rate, +# so this is a comfortable latest-wins cadence without busy-spinning the loop. +_FRAME_INTERVAL_S = 0.05 +# Status is cheap but rarely changes; emit it about once a second. +_STATUS_INTERVAL_S = 1.0 + + +class RingBroadcaster: + """Polls the controller and fans frames/status out to all WebSocket clients.""" + + def __init__(self, controller: WebController) -> None: + self._controller = controller + self._clients: set[asyncio.Queue[dict]] = set() + self._task: asyncio.Task[None] | None = None + self._last_frame_seq: int | None = None + self._last_settings: dict | None = None + + def register(self, queue: asyncio.Queue[dict]) -> None: + """Add a client queue to receive subsequent frames and status.""" + self._clients.add(queue) + + def unregister(self, queue: asyncio.Queue[dict]) -> None: + """Remove a client queue; safe to call more than once.""" + self._clients.discard(queue) + + def start(self) -> None: + """Launch the single polling task (idempotent).""" + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._run(), name="ring-broadcaster") + self._task.add_done_callback(self._on_task_done) + + @staticmethod + def _on_task_done(task: "asyncio.Task[None]") -> None: + """Surface an unexpected broadcaster death (the loop should never exit).""" + if not task.cancelled() and task.exception() is not None: + logger.error("ring broadcaster task exited unexpectedly: %r", task.exception()) + + async def stop(self) -> None: + """Cancel the polling task and wait for it to unwind.""" + if self._task is None: + return + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + + def _publish(self, message: dict) -> None: + """Push a message to every client, dropping the oldest on a full queue.""" + for queue in self._clients: + if queue.full(): + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(message) + + def _status_message(self) -> dict: + """Build a status broadcast from the controller's current state.""" + return {"type": "status", **self._controller.status()} + + async def _run(self) -> None: + """Poll on a fixed cadence; never block the loop or die on a bad frame.""" + loop = asyncio.get_running_loop() + next_status = loop.time() + while True: + try: + # peek_frame may touch shared memory / NumPy, so keep it off the loop. + frame = await loop.run_in_executor(None, self._controller.peek_frame) + if frame is not None and frame["seq"] != self._last_frame_seq: + self._last_frame_seq = frame["seq"] + self._publish(frame) + + now = loop.time() + if now >= next_status: + self._publish(self._status_message()) + # Push live settings (Qt -> web) only when they change, so the + # browser form mirrors desktop edits in real time without churn. + settings = self._controller.current_live_settings() + if settings != self._last_settings: + self._last_settings = settings + self._publish({"type": "settings", "schema": settings}) + next_status = now + _STATUS_INTERVAL_S + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - one bad frame must not stop streaming + logger.warning("ring broadcaster iteration failed; continuing", exc_info=True) + await asyncio.sleep(_FRAME_INTERVAL_S) diff --git a/requirements.txt b/requirements.txt index 560f673..8630215 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ PyVISA-py>=0.7 # pure-Python VISA backend ("@py"), required by the SN9000 (and PyQt6>=6.6 pyqtgraph>=0.13.7 rpi-hardware-pwm>=0.2.2,<1 +fastapi>=0.110 # embedded web UI (browser view + controls), served in-process +uvicorn[standard]>=0.29 # ASGI server for the web UI, run on a background thread diff --git a/run_config.json b/run_config.json index 97c2748..cb96765 100644 --- a/run_config.json +++ b/run_config.json @@ -1,18 +1,62 @@ { -"radar": { - "model": "sn9000", - "remote_host": "192.168.2.102", - "remote_port": 4880, - "driver_mode": "native", - "visa_library": "@py", - "sweep": { - "start_hz": 1000000.0, - "stop_hz": 6000000000.0, - "points": 201, - "if_bandwidth_hz": 10000.0, - "stimulus_power_dbm": -10.0 - } -}, + "radar": { + "model": "librevna", + "serial": "", + "remote_host": "127.0.0.1", + "remote_port": 50209, + "driver_mode": "mock", + "mock_signal_hz": 5000000.0, + "visa_library": "", + "multi_device": { + "slave_serials": [], + "force_external_reference": false, + "recovery_attempts": 3 + }, + "kamil_adc": { + "project_dir": "", + "executable_path": "", + "tty_path": "", + "args": [], + "env": {}, + "startup_timeout_s": 5.0, + "sweep_timeout_s": 5.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "", + "mode": "manual", + "pi_coeff1_p": 2560, + "pi_coeff1_i": 128, + "pi_coeff2_p": 2560, + "pi_coeff2_i": 128, + "manual": { + "temp1": 25.0, + "temp2": 25.0, + "current1": 30.0, + "current2": 30.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 25.0, + "static_temp2": 25.0, + "static_current1": 30.0, + "static_current2": 30.0, + "min_value": 30.0, + "max_value": 35.0, + "step": 0.1, + "time_step": 20, + "delay_time": 3 + } + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0, + "points": 201 + } + }, "switches": { "port1": { "name": "port1", @@ -40,9 +84,9 @@ } }, "control_button": { - "enabled": true, + "enabled": false, "gpio_chip": "/dev/gpiochip0", - "pin": 26, + "pin": -1, "active_low": true, "bias": "", "debounce_ms": 50, @@ -140,29 +184,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, @@ -206,6 +262,7 @@ "pass_through": { "show_magnitude": true, "show_phase": false, + "combo_filter": "", "fixed_y_enabled": false, "y_min_db": -100.0, "y_max_db": 0.0 @@ -226,11 +283,15 @@ "max_depth_m": 14.0, "range_comp_power": 0.28, "angle_comp_power": 0.1, + "score_mode": "combined", + "max_detected_objects_to_draw": 5, + "draw_top_m_objects": 2, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, "background_mean_count": 10, "remove_sidelobe_objects_enabled": false, + "imaging_plane_y_m": 0.0, "render_mode": "heatmap", "min_visible_score": 0.0, "visible_x_min_m": -2.0, @@ -248,7 +309,10 @@ "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "speed_m_s": 0.0, + "ignore_socket_speed_enabled": false, "look_angle_deg": 0.0, + "apply_freq_phase_correction": true, + "reference_mode": "frame_center", "snr_thresh": 4.5, "snr_comp_max": 25.0, "background_subtract_enabled": true, @@ -269,7 +333,8 @@ "preprocess_dialog": { "set_name": "smoke_cal", "radar_config_dir": "", - "use_all_radar_configs": false + "use_all_radar_configs": false, + "median_sweep_count": 5 } } } diff --git a/run_config_compact_m_k209.example.json b/run_config_examples/run_config_compact_m_k209.example.json similarity index 69% rename from run_config_compact_m_k209.example.json rename to run_config_examples/run_config_compact_m_k209.example.json index 58dfb95..42655e0 100644 --- a/run_config_compact_m_k209.example.json +++ b/run_config_examples/run_config_compact_m_k209.example.json @@ -6,17 +6,55 @@ "remote_port": 50209, "driver_mode": "native", "mock_signal_hz": 5000000.0, + "visa_library": "", "multi_device": { "slave_serials": [], "force_external_reference": false, "recovery_attempts": 3 }, + "kamil_adc": { + "project_dir": "", + "executable_path": "", + "tty_path": "", + "args": [], + "env": {}, + "startup_timeout_s": 5.0, + "sweep_timeout_s": 5.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "", + "mode": "manual", + "pi_coeff1_p": 2560, + "pi_coeff1_i": 128, + "pi_coeff2_p": 2560, + "pi_coeff2_i": 128, + "manual": { + "temp1": 25.0, + "temp2": 25.0, + "current1": 30.0, + "current2": 30.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 25.0, + "static_temp2": 25.0, + "static_current1": 30.0, + "static_current2": 30.0, + "min_value": 30.0, + "max_value": 35.0, + "step": 0.1, + "time_step": 20, + "delay_time": 3 + } + }, "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 201, "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 + "stimulus_power_dbm": -10.0, + "points": 201 } }, "switches": { @@ -45,6 +83,15 @@ "invert_logic": false } }, + "control_button": { + "enabled": false, + "gpio_chip": "/dev/gpiochip0", + "pin": -1, + "active_low": true, + "bias": "", + "debounce_ms": 50, + "action": "capture_tmp_reference" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, @@ -137,29 +184,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, diff --git a/run_config_compact_m_k209_local_mock_switches.example.json b/run_config_examples/run_config_compact_m_k209_local_mock_switches.example.json similarity index 70% rename from run_config_compact_m_k209_local_mock_switches.example.json rename to run_config_examples/run_config_compact_m_k209_local_mock_switches.example.json index 6238351..851dd3a 100644 --- a/run_config_compact_m_k209_local_mock_switches.example.json +++ b/run_config_examples/run_config_compact_m_k209_local_mock_switches.example.json @@ -6,17 +6,55 @@ "remote_port": 50209, "driver_mode": "native", "mock_signal_hz": 5000000.0, + "visa_library": "", "multi_device": { "slave_serials": [], "force_external_reference": false, "recovery_attempts": 3 }, + "kamil_adc": { + "project_dir": "", + "executable_path": "", + "tty_path": "", + "args": [], + "env": {}, + "startup_timeout_s": 5.0, + "sweep_timeout_s": 5.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "", + "mode": "manual", + "pi_coeff1_p": 2560, + "pi_coeff1_i": 128, + "pi_coeff2_p": 2560, + "pi_coeff2_i": 128, + "manual": { + "temp1": 25.0, + "temp2": 25.0, + "current1": 30.0, + "current2": 30.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 25.0, + "static_temp2": 25.0, + "static_current1": 30.0, + "static_current2": 30.0, + "min_value": 30.0, + "max_value": 35.0, + "step": 0.1, + "time_step": 20, + "delay_time": 3 + } + }, "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 201, "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 + "stimulus_power_dbm": -10.0, + "points": 201 } }, "switches": { @@ -45,6 +83,15 @@ "invert_logic": false } }, + "control_button": { + "enabled": false, + "gpio_chip": "/dev/gpiochip0", + "pin": -1, + "active_low": true, + "bias": "", + "debounce_ms": 50, + "action": "capture_tmp_reference" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, @@ -137,29 +184,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, diff --git a/run_config_kamil_adc.example.json b/run_config_examples/run_config_kamil_adc.example.json similarity index 87% rename from run_config_kamil_adc.example.json rename to run_config_examples/run_config_kamil_adc.example.json index 50a7c67..f64c56f 100644 --- a/run_config_kamil_adc.example.json +++ b/run_config_examples/run_config_kamil_adc.example.json @@ -2,8 +2,11 @@ "radar": { "model": "kamil_adc", "serial": "kamil_adc", + "remote_host": "127.0.0.1", + "remote_port": 50209, "driver_mode": "native", "mock_signal_hz": 5000000.0, + "visa_library": "", "multi_device": { "slave_serials": [], "force_external_reference": false, @@ -79,6 +82,15 @@ "invert_logic": false } }, + "control_button": { + "enabled": false, + "gpio_chip": "/dev/gpiochip0", + "pin": -1, + "active_low": true, + "bias": "", + "debounce_ms": 50, + "action": "capture_tmp_reference" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, @@ -171,29 +183,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, diff --git a/run_config_librevna.example.json b/run_config_examples/run_config_librevna.example.json similarity index 68% rename from run_config_librevna.example.json rename to run_config_examples/run_config_librevna.example.json index b887627..a82eddd 100644 --- a/run_config_librevna.example.json +++ b/run_config_examples/run_config_librevna.example.json @@ -2,19 +2,59 @@ "radar": { "model": "librevna", "serial": "", + "remote_host": "127.0.0.1", + "remote_port": 50209, "driver_mode": "native", "mock_signal_hz": 5000000.0, + "visa_library": "", "multi_device": { "slave_serials": [], "force_external_reference": false, "recovery_attempts": 3 }, + "kamil_adc": { + "project_dir": "", + "executable_path": "", + "tty_path": "", + "args": [], + "env": {}, + "startup_timeout_s": 5.0, + "sweep_timeout_s": 5.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "", + "mode": "manual", + "pi_coeff1_p": 2560, + "pi_coeff1_i": 128, + "pi_coeff2_p": 2560, + "pi_coeff2_i": 128, + "manual": { + "temp1": 25.0, + "temp2": 25.0, + "current1": 30.0, + "current2": 30.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 25.0, + "static_temp2": 25.0, + "static_current1": 30.0, + "static_current2": 30.0, + "min_value": 30.0, + "max_value": 35.0, + "step": 0.1, + "time_step": 20, + "delay_time": 3 + } + }, "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 201, "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 + "stimulus_power_dbm": -10.0, + "points": 201 } }, "switches": { @@ -43,6 +83,15 @@ "invert_logic": false } }, + "control_button": { + "enabled": false, + "gpio_chip": "/dev/gpiochip0", + "pin": -1, + "active_low": true, + "bias": "", + "debounce_ms": 50, + "action": "capture_tmp_reference" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, @@ -135,29 +184,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, diff --git a/run_config_librevna_multi.example.json b/run_config_examples/run_config_librevna_multi.example.json similarity index 69% rename from run_config_librevna_multi.example.json rename to run_config_examples/run_config_librevna_multi.example.json index c7da2d1..c2adc18 100644 --- a/run_config_librevna_multi.example.json +++ b/run_config_examples/run_config_librevna_multi.example.json @@ -2,8 +2,11 @@ "radar": { "model": "librevna_multi", "serial": "207730885532", + "remote_host": "127.0.0.1", + "remote_port": 50209, "driver_mode": "native", "mock_signal_hz": 5000000.0, + "visa_library": "", "multi_device": { "slave_serials": [ "20A1307D5532", @@ -12,12 +15,49 @@ "force_external_reference": true, "recovery_attempts": 3 }, + "kamil_adc": { + "project_dir": "", + "executable_path": "", + "tty_path": "", + "args": [], + "env": {}, + "startup_timeout_s": 5.0, + "sweep_timeout_s": 5.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "", + "mode": "manual", + "pi_coeff1_p": 2560, + "pi_coeff1_i": 128, + "pi_coeff2_p": 2560, + "pi_coeff2_i": 128, + "manual": { + "temp1": 25.0, + "temp2": 25.0, + "current1": 30.0, + "current2": 30.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 25.0, + "static_temp2": 25.0, + "static_current1": 30.0, + "static_current2": 30.0, + "min_value": 30.0, + "max_value": 35.0, + "step": 0.1, + "time_step": 20, + "delay_time": 3 + } + }, "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 201, "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 + "stimulus_power_dbm": -10.0, + "points": 201 } }, "switches": { @@ -46,6 +86,15 @@ "invert_logic": false } }, + "control_button": { + "enabled": false, + "gpio_chip": "/dev/gpiochip0", + "pin": -1, + "active_low": true, + "bias": "", + "debounce_ms": 50, + "action": "capture_tmp_reference" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, @@ -138,29 +187,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, diff --git a/run_config_simulator.example.json b/run_config_examples/run_config_simulator.example.json similarity index 74% rename from run_config_simulator.example.json rename to run_config_examples/run_config_simulator.example.json index ace723b..cb96765 100644 --- a/run_config_simulator.example.json +++ b/run_config_examples/run_config_simulator.example.json @@ -2,19 +2,59 @@ "radar": { "model": "librevna", "serial": "", + "remote_host": "127.0.0.1", + "remote_port": 50209, "driver_mode": "mock", "mock_signal_hz": 5000000.0, + "visa_library": "", "multi_device": { "slave_serials": [], "force_external_reference": false, "recovery_attempts": 3 }, + "kamil_adc": { + "project_dir": "", + "executable_path": "", + "tty_path": "", + "args": [], + "env": {}, + "startup_timeout_s": 5.0, + "sweep_timeout_s": 5.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "", + "mode": "manual", + "pi_coeff1_p": 2560, + "pi_coeff1_i": 128, + "pi_coeff2_p": 2560, + "pi_coeff2_i": 128, + "manual": { + "temp1": 25.0, + "temp2": 25.0, + "current1": 30.0, + "current2": 30.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 25.0, + "static_temp2": 25.0, + "static_current1": 30.0, + "static_current2": 30.0, + "min_value": 30.0, + "max_value": 35.0, + "step": 0.1, + "time_step": 20, + "delay_time": 3 + } + }, "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 201, "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 + "stimulus_power_dbm": -10.0, + "points": 201 } }, "switches": { @@ -43,6 +83,15 @@ "invert_logic": false } }, + "control_button": { + "enabled": false, + "gpio_chip": "/dev/gpiochip0", + "pin": -1, + "active_low": true, + "bias": "", + "debounce_ms": 50, + "action": "capture_tmp_reference" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, @@ -135,29 +184,41 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905 + "x_m": 0.905, + "y_m": 0.0, + "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905 + "x_m": -0.905, + "y_m": 0.0, + "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18 + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485 + "x_m": 0.485, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49 + "x_m": -0.49, + "y_m": 0.0, + "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185 + "x_m": 0.185, + "y_m": 0.0, + "z_m": 0.0 } ] }, @@ -201,6 +262,7 @@ "pass_through": { "show_magnitude": true, "show_phase": false, + "combo_filter": "", "fixed_y_enabled": false, "y_min_db": -100.0, "y_max_db": 0.0 @@ -221,11 +283,15 @@ "max_depth_m": 14.0, "range_comp_power": 0.28, "angle_comp_power": 0.1, + "score_mode": "combined", + "max_detected_objects_to_draw": 5, + "draw_top_m_objects": 2, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, "background_mean_count": 10, "remove_sidelobe_objects_enabled": false, + "imaging_plane_y_m": 0.0, "render_mode": "heatmap", "min_visible_score": 0.0, "visible_x_min_m": -2.0, @@ -243,7 +309,10 @@ "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "speed_m_s": 0.0, + "ignore_socket_speed_enabled": false, "look_angle_deg": 0.0, + "apply_freq_phase_correction": true, + "reference_mode": "frame_center", "snr_thresh": 4.5, "snr_comp_max": 25.0, "background_subtract_enabled": true, @@ -264,7 +333,8 @@ "preprocess_dialog": { "set_name": "smoke_cal", "radar_config_dir": "", - "use_all_radar_configs": false + "use_all_radar_configs": false, + "median_sweep_count": 5 } } } diff --git a/run_config_kamil_adc.pi.json b/run_config_kamil_adc.pi.json deleted file mode 100644 index 1a42784..0000000 --- a/run_config_kamil_adc.pi.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "radar": { - "model": "kamil_adc", - "serial": "kamil_adc", - "driver_mode": "native", - "kamil_adc": { - "project_dir": "/home/callisto/Documents/kamil_adc", - "executable_path": "/home/callisto/Documents/kamil_adc/run_do1_pair_subtract_avg.sh", - "tty_path": "/tmp/ttyADC_data", - "args": [], - "env": { - "TTY_PATH": "/tmp/ttyADC_data" - }, - "startup_timeout_s": 10.0, - "sweep_timeout_s": 15.0, - "stop_timeout_s": 2.0 - }, - "laser_control": { - "enabled": false, - "port": "/dev/ttyUSB0", - "mode": "variation" - }, - "sweep": { - "start_hz": 1000000.0, - "stop_hz": 6000000000.0, - "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0 - } - }, - "switches": { - "port1": { - "name": "port1", - "driver_mode": "mock", - "driver": "h7992", - "radar_port": 1, - "positions": 1, - "default_position": 0 - }, - "port2": { - "name": "port2", - "driver_mode": "mock", - "driver": "h7992", - "radar_port": 2, - "positions": 1, - "default_position": 0 - } - }, - "run": { - "settling_ms": 0, - "idle_sleep_ms": 2, - "continuous": true, - "processing_live_config_path": "python_app/runtime/processing_live.json", - "combos": [ - { - "input": 0, - "output": 0 - } - ] - }, - "preprocess": { - "s21": { - "calibration": { - "set_name": "", - "bundle_path": "" - }, - "reference": { - "set_name": "", - "bundle_path": "" - } - }, - "s11": { - "calibration": { - "open": { - "set_name": "", - "bundle_path": "" - }, - "short": { - "set_name": "", - "bundle_path": "" - }, - "load": { - "set_name": "", - "bundle_path": "" - } - }, - "reference": { - "set_name": "", - "bundle_path": "" - } - }, - "notch": { - "enabled": false, - "bands_hz": [], - "taper_width_hz": 40000000.0, - "taper_type": "cosine" - } - }, - "gpr": { - "relative_permittivity": 1.0, - "tx_geometry": [ - { - "output_pos": 0, - "x_m": 0.0 - } - ], - "rx_geometry": [ - { - "input_pos": 0, - "x_m": 0.0 - } - ] - }, - "rings": { - "raw": { - "name": "/radar_raw_kamil_adc", - "capacity": 50, - "slot_size_bytes": 2097152 - }, - "raw_tap": { - "name": "/radar_raw_tap_kamil_adc", - "capacity": 50, - "slot_size_bytes": 2097152 - }, - "preprocessed": { - "name": "/radar_preprocessed_kamil_adc", - "capacity": 50, - "slot_size_bytes": 2097152 - }, - "preprocessed_tap": { - "name": "/radar_preprocessed_tap_kamil_adc", - "capacity": 50, - "slot_size_bytes": 2097152 - }, - "results": { - "name": "/radar_results_kamil_adc", - "capacity": 50, - "slot_size_bytes": 2097152 - } - } -}