web UI added and refactoring done

This commit is contained in:
Ayzen
2026-06-06 00:06:30 +03:00
parent 3c30a12d4a
commit af6005d68f
65 changed files with 3630 additions and 4720 deletions
-14
View File
@@ -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)"
]
}
}
+1
View File
@@ -223,3 +223,4 @@ __marimo__/
# Streamlit # Streamlit
.streamlit/secrets.toml .streamlit/secrets.toml
python_app/runtime python_app/runtime
SHARE_INTERNET_TO_PI.md
-731
View File
@@ -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()
-956
View File
@@ -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()
-1086
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -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:
-14
View File
@@ -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:
-14
View File
@@ -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:
@@ -141,6 +141,7 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
std::vector<std::uint8_t> serialized_raw{}; std::vector<std::uint8_t> serialized_raw{};
ipc::RawSweepCollection raw_collection{}; ipc::RawSweepCollection raw_collection{};
std::uint64_t error_count = 0; std::uint64_t error_count = 0;
std::uint64_t consecutive_errors = 0;
while (!stop_requested.load(std::memory_order_relaxed)) { while (!stop_requested.load(std::memory_order_relaxed)) {
try { try {
if (!try_pop_raw_collection(serialized_raw, &raw_collection)) { if (!try_pop_raw_collection(serialized_raw, &raw_collection)) {
@@ -148,6 +149,7 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
} }
const auto preprocessed_collection = preprocess_collection(raw_collection); const auto preprocessed_collection = preprocess_collection(raw_collection);
publish_preprocessed_collection(preprocessed_collection); publish_preprocessed_collection(preprocessed_collection);
consecutive_errors = 0; // made progress; reset the escalation counter
} catch (const std::exception& exc) { } catch (const std::exception& exc) {
// A ring-slot-too-small failure is a permanent config error (the slot // A ring-slot-too-small failure is a permanent config error (the slot
// cannot hold a serialized collection): swallowing it would silently // cannot hold a serialized collection): swallowing it would silently
@@ -157,12 +159,22 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
} }
// A single malformed or transiently-bad collection must not kill the // A single malformed or transiently-bad collection must not kill the
// long-running preprocessor: drop it and keep serving the next sweep. // 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; ++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<int>(1, config_.runtime.idle_sleep_ms)));
} }
} }
} }
@@ -76,6 +76,12 @@ struct ProcessingLiveConfig {
float legacy_gpr_min_visible_pair_count = 0.0F; float legacy_gpr_min_visible_pair_count = 0.0F;
std::uint32_t gpr_max_detected_objects_to_draw = 0; std::uint32_t gpr_max_detected_objects_to_draw = 0;
std::uint32_t gpr_draw_top_m_objects = 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 // 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 // keeps using `gpr_speed_m_s` from this file. Mirrored from the GUI's
// "ignore socket speed" checkbox. // "ignore socket speed" checkbox.
@@ -64,6 +64,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::uint64_t last_replayed_revision = live_config_loader_.revision(); std::uint64_t last_replayed_revision = live_config_loader_.revision();
std::uint64_t last_applied_history_command_seq = 0; std::uint64_t last_applied_history_command_seq = 0;
std::uint64_t error_count = 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 // 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 // arriving without a live-config revision bump still triggers a reprocess of
// the current result (gated below by reprocess_current_result). // the current result (gated below by reprocess_current_result).
@@ -143,6 +144,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
); );
publish_result_collection(result_collection, results_ring_); publish_result_collection(result_collection, results_ring_);
publish_locator(result_collection, live_config); publish_locator(result_collection, live_config);
consecutive_errors = 0; // made progress; reset the escalation counter
continue; continue;
} }
@@ -155,14 +157,23 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
throw; throw;
} }
// A single bad collection (torn ring slot, decode/processing error) must // A single bad collection (torn ring slot, decode/processing error) must
// not kill the long-running processor: drop it and keep going. Throttle // not kill the long-running processor: drop it and keep going.
// 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';
}
++error_count; ++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<int>(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; live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
radar::locator::FilterParams filter{}; 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") { if (requested_mode == "legacy_gpr") {
filter.min_score = live_config.legacy_gpr_min_visible_pair_count; filter.min_score = live_config.legacy_gpr_min_visible_pair_count;
// The GUI deliberately disables the "draw top N" capping for legacy // The GUI deliberately disables the "draw top N" capping for legacy
@@ -348,6 +348,19 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
} }
config.gpr_min_visible_score = static_cast<float>(found->get<double>()); config.gpr_min_visible_score = static_cast<float>(found->get<double>());
} }
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<float>(found->get<double>());
}
}
if (const auto found = root.find("legacy_gpr_min_visible_pair_count"); found != root.end()) { if (const auto found = root.find("legacy_gpr_min_visible_pair_count"); found != root.end()) {
if (!found->is_number()) { if (!found->is_number()) {
throw std::runtime_error("processing.legacy_gpr_min_visible_pair_count must be number"); throw std::runtime_error("processing.legacy_gpr_min_visible_pair_count must be number");
@@ -511,9 +511,17 @@ void TcpServer::acceptor_loop() {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue; continue;
} }
// Listening socket closed during shutdown produces EBADF/EINVAL; bail. // 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; break;
} }
std::cerr << "locator: accept() failed (errno=" << errno << "); retrying\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
reap_finished_clients(); reap_finished_clients();
-308
View File
@@ -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
```
-295
View File
@@ -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:<tty_path>` 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.
-356
View File
@@ -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<Complex32> 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_label> 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.
-479
View File
@@ -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:<tty_path>`. |
| `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"
}
}
```
-140
View File
@@ -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::<radar.remote_host>::hislip0,<radar.remote_port>::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).
+5
View File
@@ -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_preprocess_mixin import AppWindowPreprocessMixin
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin 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_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.gui.preprocess_dialog import PreprocessDialog
from python_app.models.dataset_model import ResultCollection, SweepCollection from python_app.models.dataset_model import ResultCollection, SweepCollection
from python_app.models.gui_profile_model import GuiProfileModel from python_app.models.gui_profile_model import GuiProfileModel
@@ -53,6 +54,7 @@ class AppWindow(
AppWindowPipelineMixin, AppWindowPipelineMixin,
AppWindowSnapshotMixin, AppWindowSnapshotMixin,
AppWindowControlButtonMixin, AppWindowControlButtonMixin,
AppWindowWebMixin,
QMainWindow, QMainWindow,
): ):
"""Top-level window coordinating GUI state and acquisition runtime.""" """Top-level window coordinating GUI state and acquisition runtime."""
@@ -288,6 +290,7 @@ class AppWindow(
self._timer.start() self._timer.start()
self._maybe_auto_start_pipeline() self._maybe_auto_start_pipeline()
self._start_control_button_watcher() self._start_control_button_watcher()
self._init_web_ui()
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"): if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
self._install_headless_watchdog() self._install_headless_watchdog()
@@ -629,6 +632,8 @@ class AppWindow(
return return
self._closing = True self._closing = True
try: 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. # 0) Stop the GPIO button watcher so a late press cannot start work.
self._stop_control_button_watcher() self._stop_control_button_watcher()
self._resume_pipeline_after_capture = False self._resume_pipeline_after_capture = False
@@ -3,10 +3,125 @@
from __future__ import annotations from __future__ import annotations
from PyQt6.QtCore import QSignalBlocker 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.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.orchestration.live_processing_config import ProcessingLiveConfig 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: class AppWindowLiveProcessingMixin:
"""Handle live processing updates, redraws, and locator republishing.""" """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_min_db = float(self._pass_through_y_min_db.value())
y_max_db = float(self._pass_through_y_max_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, processor_mode=mode,
pass_through_channel="s21", pass_through_channel="s21",
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()), 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_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
gpr_min_visible_score=float(self._gpr_min_visible_score.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()), 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()), ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
reprocess_current_result=bool(reprocess_current_result), reprocess_current_result=bool(reprocess_current_result),
history_command_seq=int(self._history_command_seq), history_command_seq=int(self._history_command_seq),
history_command=str(history_command), history_command=str(history_command),
) )
return config
def _write_live_processing_config( def _write_live_processing_config(
self, 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: def _on_processing_live_settings_changed(self, *_args) -> None:
"""Handle live-processing setting changes and trigger redraw when needed.""" """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: try:
current_mode = self._processing_mode.currentText() current_mode = self._processing_mode.currentText()
if self._is_gpr_processing_mode(current_mode): if self._is_gpr_processing_mode(current_mode):
@@ -22,6 +22,7 @@ from python_app.orchestration.preprocess_assets import (
preprocess_asset_model, preprocess_asset_model,
runtime_preprocess_asset_keys, runtime_preprocess_asset_keys,
) )
from python_app.orchestration.restart_policy import RestartPolicy
from python_app.orchestration.shm_reader import ShmRingReader from python_app.orchestration.shm_reader import ShmRingReader
@@ -36,6 +37,15 @@ class AppWindowPipelineMixin:
# a wedged reader cannot stay silently broken forever. # a wedged reader cannot stay silently broken forever.
_READER_ERROR_RECONNECT_AT = 40 _READER_ERROR_RECONNECT_AT = 40
_READER_ERROR_STOP_AT = 400 _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: def _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool:
"""Return whether alive `data_processor` was started with different stable run settings.""" """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._drop_pending_ring_payloads(include_results=True)
self._last_reader_error_signature = None self._last_reader_error_signature = None
self._reader_error_repeat_count = 0 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: if single_capture:
self._single_capture_start_ns = time.monotonic_ns() self._single_capture_start_ns = time.monotonic_ns()
@@ -246,6 +262,7 @@ class AppWindowPipelineMixin:
def _stop_run(self) -> None: def _stop_run(self) -> None:
"""Stop acquisition-side processes and close readers as needed.""" """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() was_running = self._supervisor.is_running()
if was_running: if was_running:
self._supervisor.stop_orchestrator() self._supervisor.stop_orchestrator()
@@ -271,6 +288,7 @@ class AppWindowPipelineMixin:
def _stop_all_processes(self) -> None: def _stop_all_processes(self) -> None:
"""Stop all managed pipeline processes and close all readers.""" """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() was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
self._supervisor.stop_all() self._supervisor.stop_all()
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02) self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
@@ -299,12 +317,8 @@ class AppWindowPipelineMixin:
def _poll_rings(self) -> None: def _poll_rings(self) -> None:
"""Poll readers, ingest history, and trigger rendering.""" """Poll readers, ingest history, and trigger rendering."""
for report in self._supervisor.collect_exit_reports(): self._web_update_snapshot() # guarded internally; never raises
if report.level == "INFO": self._handle_process_exit_reports()
self._log(report.format())
continue
self._status_label.setText("Status: error")
self._log_error(report.format())
try: try:
if self._raw_reader is not None: if self._raw_reader is not None:
@@ -318,9 +332,13 @@ class AppWindowPipelineMixin:
if self._single_capture_active: if self._single_capture_active:
if self._finish_single_capture_if_ready(): if self._finish_single_capture_if_ready():
return return
self._check_single_capture_deadline()
return return
if result_latest is not None: 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() render_started_ns = time.monotonic_ns()
self._draw_preferred_collection(result_latest=result_latest) self._draw_preferred_collection(result_latest=result_latest)
self._pipeline_metrics.record( self._pipeline_metrics.record(
@@ -331,6 +349,88 @@ class AppWindowPipelineMixin:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._handle_reader_poll_error(exc) 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: def _handle_reader_poll_error(self, exc: Exception) -> None:
"""Surface a reader-poll failure without spamming the log. """Surface a reader-poll failure without spamming the log.
@@ -8,8 +8,10 @@ import pyqtgraph as pg
from python_app.models.dataset_model import ResultCollection from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.gpr_locator import ( 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_payload_by_name as gpr_collection_payload_by_name,
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix, 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, gpr_object_rows as extract_gpr_object_rows,
) )
@@ -252,13 +254,12 @@ class AppWindowGprPlotMixin:
self._draw_gpr_geometry_markers() 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) 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: if points_payload is not None and np.asarray(points_payload.table).size > 0:
points = ( points = self._filtered_gpr_object_rows(collection)
self._filtered_gpr_object_rows(collection)
if self._processing_mode.currentText() == "gpr"
else np.asarray(points_payload.table, dtype=np.float32)
)
else: else:
points = np.zeros((0, 3), dtype=np.float32) points = np.zeros((0, 3), dtype=np.float32)
@@ -388,13 +389,7 @@ class AppWindowGprPlotMixin:
@staticmethod @staticmethod
def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray: 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.""" """Apply object count/top-M drawing rules to already-filtered rows."""
if limits is None or rows.size == 0: return gpr_apply_object_draw_limits(rows, limits)
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))]
@staticmethod @staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float: def _gpr_display_y_min(z_min: float, z_max: float) -> float:
@@ -517,17 +512,13 @@ class AppWindowGprPlotMixin:
return rows return rows
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds() x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
min_score = self._gpr_locator_threshold() return gpr_filter_object_rows(
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1) rows,
visible_mask = ( min_score=self._gpr_locator_threshold(),
finite_mask x_bounds=(x_min, x_max),
& (rows[:, 2] >= min_score) z_bounds=(z_min, z_max),
& (rows[:, 0] >= x_min) draw_limits=self._gpr_draw_limits(),
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
) )
return self._apply_object_draw_limits(rows[visible_mask], self._gpr_draw_limits())
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool: def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
"""Draw only detected GPR objects inside configured X/Z bounds.""" """Draw only detected GPR objects inside configured X/Z bounds."""
@@ -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
@@ -342,7 +342,10 @@ class Protocol:
case TaskType.CHANGE_CURRENT_LD2: 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(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4 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(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(temp_c_to_n(static_temp2))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8 data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8
+6
View File
@@ -17,6 +17,7 @@ from python_app.models.run_config_validation import (
load_control_button_payload, load_control_button_payload,
load_ring_payload, load_ring_payload,
load_switch_payload, load_switch_payload,
validate_combos,
validate_gpr_model, validate_gpr_model,
) )
@@ -431,6 +432,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
) )
model.ensure_combos() model.ensure_combos()
validate_combos(
model.combos,
input_positions=model.input_switch.positions,
output_positions=model.output_switch.positions,
)
return model return model
+4 -3
View File
@@ -21,9 +21,10 @@ class ComboModel:
class RadarSweepModel: class RadarSweepModel:
"""Sweep settings for LibreVNA acquisition.""" """Sweep settings for LibreVNA acquisition."""
# Keep schema defaults minimal/safe; operational values come from run_config.json. # A valid default range (stop > start) so a bare/default config is self-consistent;
start_hz: float = 0.0 # operational values come from run_config.json. (1 MHz .. 6 GHz mirrors the real configs.)
stop_hz: float = 0.0 start_hz: float = 1_000_000.0
stop_hz: float = 6_000_000_000.0
points: int = 1 points: int = 1
if_bandwidth_hz: float = 1.0 if_bandwidth_hz: float = 1.0
power_dbm: float = -30.0 power_dbm: float = -30.0
+36 -10
View File
@@ -24,20 +24,18 @@ _MAX_COMBOS = 4096
def _require_int(payload: dict[str, Any], key: str, default: int) -> int: 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 Accept only a genuine JSON integer (not bool, not float, not numeric string):
config-error contract; surface it as a ValueError naming the field instead. 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) value = payload.get(key, default)
if value is None: # explicit JSON null -> use the default, never coerce if value is None: # explicit JSON null -> use the default, never coerce
return default 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") raise ValueError(f"{key} must be a JSON integer")
try: return value
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON integer") from exc
def _require_str(payload: dict[str, Any], key: str, default: str) -> str: 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") raise ValueError("radar.sweep.points must be an integer")
if points <= 0: if points <= 0:
raise ValueError("radar.sweep.points must be > 0") raise ValueError("radar.sweep.points must be > 0")
if float(sweep.stop_hz) < float(sweep.start_hz): if float(sweep.stop_hz) <= float(sweep.start_hz):
raise ValueError("radar.sweep.stop_hz must be >= radar.sweep.start_hz") raise ValueError("radar.sweep.stop_hz must be > radar.sweep.start_hz")
def validate_gpr_model( def validate_gpr_model(
@@ -173,6 +171,34 @@ def validate_gpr_model(
seen_input_positions.add(input_pos) 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]: def parse_combos_from_text(text: str) -> list[ComboModel]:
"""Parse UI combos string in `input:output,input:output` format.""" """Parse UI combos string in `input:output,input:output` format."""
cleaned = text.strip() cleaned = text.strip()
+49
View File
@@ -65,3 +65,52 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
return centers[:, :3] return centers[:, :3]
return np.zeros((0, 3), dtype=np.float32) 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)
@@ -57,6 +57,12 @@ class ProcessingLiveConfig:
# Locator filter parameters consumed by the C++ TCP locator server. # Locator filter parameters consumed by the C++ TCP locator server.
gpr_min_visible_score: float = 0.0 gpr_min_visible_score: float = 0.0
legacy_gpr_min_visible_pair_count: 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 # When true, the C++ data_processor ignores socket-supplied vlc updates
# and keeps using `gpr_speed_m_s` from this file. # and keeps using `gpr_speed_m_s` from this file.
ignore_socket_speed: bool = False ignore_socket_speed: bool = False
@@ -116,6 +122,10 @@ class ProcessingLiveConfig:
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
"gpr_min_visible_score": float(self.gpr_min_visible_score), "gpr_min_visible_score": float(self.gpr_min_visible_score),
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count), "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), "ignore_socket_speed": bool(self.ignore_socket_speed),
"reprocess_current_result": bool(self.reprocess_current_result), "reprocess_current_result": bool(self.reprocess_current_result),
"history_command_seq": int(self.history_command_seq), "history_command_seq": int(self.history_command_seq),
@@ -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)
+34 -21
View File
@@ -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 from __future__ import annotations
@@ -6,48 +11,56 @@ import struct
class ByteCursor: 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: def __init__(self, payload: bytes) -> None:
"""Create cursor at start of payload.""" """Create cursor at start of payload."""
self.payload = payload self.payload = payload
self.offset = 0 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: def read_u8(self) -> int:
"""Read unsigned 8-bit integer.""" """Read unsigned 8-bit integer."""
value = struct.unpack_from("<B", self.payload, self.offset)[0] return struct.unpack("<B", self._take(1))[0]
self.offset += 1
return value
def read_u16(self) -> int: def read_u16(self) -> int:
"""Read unsigned 16-bit integer.""" """Read unsigned 16-bit integer."""
value = struct.unpack_from("<H", self.payload, self.offset)[0] return struct.unpack("<H", self._take(2))[0]
self.offset += 2
return value
def read_u32(self) -> int: def read_u32(self) -> int:
"""Read unsigned 32-bit integer.""" """Read unsigned 32-bit integer."""
value = struct.unpack_from("<I", self.payload, self.offset)[0] return struct.unpack("<I", self._take(4))[0]
self.offset += 4
return value
def read_u64(self) -> int: def read_u64(self) -> int:
"""Read unsigned 64-bit integer.""" """Read unsigned 64-bit integer."""
value = struct.unpack_from("<Q", self.payload, self.offset)[0] return struct.unpack("<Q", self._take(8))[0]
self.offset += 8
return value
def read_f32(self) -> float: def read_f32(self) -> float:
"""Read 32-bit float.""" """Read 32-bit float."""
value = struct.unpack_from("<f", self.payload, self.offset)[0] return float(struct.unpack("<f", self._take(4))[0])
self.offset += 4
return float(value)
def read_bytes(self, size: int) -> bytes: def read_bytes(self, size: int) -> bytes:
"""Read raw byte slice of fixed size.""" """Read raw byte slice of fixed size (bounds-checked)."""
data = self.payload[self.offset : self.offset + size] return self._take(size)
self.offset += size
return data 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: def remaining_bytes(self) -> int:
"""Return unread byte count.""" """Return unread byte count."""
+1 -1
View File
@@ -78,7 +78,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
"""Decode one result payload from stream.""" """Decode one result payload from stream."""
kind = cursor.read_u8() kind = cursor.read_u8()
name_size = cursor.read_u16() name_size = cursor.read_u16()
name = cursor.read_bytes(name_size).decode("utf-8") name = cursor.read_str(name_size)
if kind == 1: if kind == 1:
point_count = cursor.read_u32() point_count = cursor.read_u32()
@@ -35,12 +35,20 @@ class ShmRingReader:
self._wait_for_ring_file(timeout_s=open_timeout_s, poll_s=open_poll_s) 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._file = self._path.open("r+b", buffering=0)
self._mmap: mmap.mmap | None = None
try:
self._mmap = mmap.mmap(self._file.fileno(), 0) self._mmap = mmap.mmap(self._file.fileno(), 0)
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002) 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: def close(self) -> None:
"""Close mmap and file handle.""" """Close mmap and file handle."""
if self._mmap is not None:
self._mmap.close() self._mmap.close()
self._mmap = None
self._file.close() self._file.close()
def pop_payload(self) -> bytes | None: def pop_payload(self) -> bytes | None:
@@ -103,6 +111,45 @@ class ShmRingReader:
return None return None
return decode_result_collection(payload) 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: def drop_all(self) -> int:
"""Mark all unread slots as consumed and return number of dropped payloads.""" """Mark all unread slots as consumed and return number of dropped payloads."""
write_seq = self._read_u64(24) write_seq = self._read_u64(24)
+6 -3
View File
@@ -98,9 +98,12 @@ class ShmRingWriter:
write_seq = self._read_u64(24) write_seq = self._read_u64(24)
read_seq = self._read_u64(32) read_seq = self._read_u64(32)
if max(0, write_seq - read_seq) >= self._capacity: if max(0, write_seq - read_seq) >= self._capacity:
self._write_u64(32, read_seq + 1) # Advance the consumer cursor past the slot we are about to overwrite, but
dropped = self._read_u64(40) # re-read it first and move it only forward: a concurrent reader may have
self._write_u64(40, dropped + 1) # 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 index = write_seq % self._capacity
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes) slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
+11 -7
View File
@@ -136,6 +136,7 @@ def main() -> int:
return 0 # asked to stop before a device became available return 0 # asked to stop before a device became available
collection_id = 1 collection_id = 1
publish_failures = 0
while not stop_requested.is_set(): while not stop_requested.is_set():
collection_start = time.monotonic() collection_start = time.monotonic()
capture_start_ns = time.monotonic_ns() capture_start_ns = time.monotonic_ns()
@@ -193,13 +194,16 @@ def main() -> int:
capture_end_ns=time.monotonic_ns(), capture_end_ns=time.monotonic_ns(),
) )
payload = serialize_trace_collection(collection, RAW_MAGIC) payload = serialize_trace_collection(collection, RAW_MAGIC)
if not raw_writer.push(payload): if raw_writer.push(payload):
raise RuntimeError( raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}" else:
) # Oversized payload vs the ring slot is a persistent config error, not
if not raw_tap_writer.push(payload): # a device fault: log (throttled) and skip rather than killing the producer.
raise RuntimeError( publish_failures += 1
f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}" 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: if not config.runtime.continuous:
+12 -7
View File
@@ -117,6 +117,7 @@ def main() -> int:
if radar is None: if radar is None:
return 0 # asked to stop before a device became available return 0 # asked to stop before a device became available
collection_id = 1 collection_id = 1
publish_failures = 0
while not stop_requested.is_set(): while not stop_requested.is_set():
collection_start = time.monotonic() collection_start = time.monotonic()
try: try:
@@ -133,13 +134,17 @@ def main() -> int:
continue continue
payload = serialize_trace_collection(collection, RAW_MAGIC) payload = serialize_trace_collection(collection, RAW_MAGIC)
if not raw_writer.push(payload): if raw_writer.push(payload):
raise RuntimeError( raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}" else:
) # An oversized payload vs the ring slot is a persistent config error,
if not raw_tap_writer.push(payload): # not a device fault: log it (throttled) and skip the collection rather
raise RuntimeError( # than letting a RuntimeError escape the loop and kill the producer.
f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}" 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: if not config.runtime.continuous:
break break
@@ -30,6 +30,42 @@ class GuiProfileCodecTest(unittest.TestCase):
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0") 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") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -60,6 +60,46 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "kamil_adc"): with self.assertRaisesRegex(ValueError, "kamil_adc"):
build_kamil_adc_neutral_s21_sets(config, point_count=4) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -140,6 +140,23 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
finally: finally:
self._close(master_fd, slave_fd, reader) 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: def test_no_completed_sweep_times_out(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader() master_fd, slave_fd, reader = self._open_pty_reader()
try: try:
@@ -17,7 +17,9 @@ DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600" "7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
) )
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = ( 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(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_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: def test_start_sequence_matches_device_main_order(self) -> None:
fake_protocol = _FakeProtocol() fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) 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, "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: def test_apply_radar_skips_disabled_laser_control(self) -> None:
config = self._kamil_config({"enabled": False}) config = self._kamil_config({"enabled": False})
@@ -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()
+296
View File
@@ -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()
+232
View File
@@ -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()
+247
View File
@@ -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("<IQQI", RAW_MAGIC, 1, 1, 1_000_000)
with self.assertRaises(ValueError):
decode_trace_collection(corrupt, RAW_MAGIC)
def test_unsupported_payload_kind(self) -> None:
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 99, 0)
with self.assertRaises(ValueError):
decode_result_collection(corrupt)
def test_invalid_utf8_name(self) -> None:
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 2, 1) + b"\xff"
with self.assertRaises(ValueError):
decode_result_collection(corrupt)
class _RingTestCase(unittest.TestCase):
"""Base case that creates/cleans named /dev/shm rings."""
def _ring_name(self, suffix: str = "") -> 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()
+212
View File
@@ -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()
+15
View File
@@ -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
+43
View File
@@ -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
+42
View File
@@ -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``."""
+87
View File
@@ -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()
+53
View File
@@ -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)
+298
View File
@@ -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 + ": <b></b>";
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();
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radar System</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">Radar System</div>
<div class="controls">
<button id="btn-start" class="btn">Start</button>
<button id="btn-single" class="btn">Single Capture</button>
<button id="btn-stop" class="btn">Stop</button>
<button id="btn-tmp-ref" class="btn">Tmp Reference</button>
</div>
</header>
<main class="layout">
<section class="plot-panel">
<div class="plot-head">
<span class="plot-title">Processor output</span>
</div>
<div class="canvas-wrap">
<img id="plot" class="plot-img" alt="Live processor plot" />
</div>
</section>
<aside class="side-panel">
<div class="panel-head" id="settings-toggle">
<span class="panel-title">Processor settings</span>
<span class="chevron" id="settings-chevron"></span>
</div>
<div class="panel-body" id="settings-body">
<div id="settings-fields" class="settings-fields"></div>
<div class="settings-actions">
<button id="btn-apply" class="btn primary">Apply</button>
<button id="btn-reset-history" class="btn">Reset history</button>
</div>
<div id="settings-note" class="note"></div>
</div>
</aside>
</main>
<footer class="statusbar">
<span class="stat" id="stat-running">running: —</span>
<span class="stat" id="stat-processor">processor: —</span>
<span class="stat" id="stat-ring">ring: —</span>
<span class="stat" id="stat-stale">live</span>
</footer>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>
+289
View File
@@ -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; }
}
+105
View File
@@ -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)
+2
View File
@@ -6,3 +6,5 @@ PyVISA-py>=0.7 # pure-Python VISA backend ("@py"), required by the SN9000 (and
PyQt6>=6.6 PyQt6>=6.6
pyqtgraph>=0.13.7 pyqtgraph>=0.13.7
rpi-hardware-pwm>=0.2.2,<1 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
+84 -19
View File
@@ -1,18 +1,62 @@
{ {
"radar": { "radar": {
"model": "sn9000", "model": "librevna",
"remote_host": "192.168.2.102", "serial": "",
"remote_port": 4880, "remote_host": "127.0.0.1",
"driver_mode": "native", "remote_port": 50209,
"visa_library": "@py", "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": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201, "if_bandwidth_hz": 50000.0,
"if_bandwidth_hz": 10000.0, "stimulus_power_dbm": -10.0,
"stimulus_power_dbm": -10.0 "points": 201
} }
}, },
"switches": { "switches": {
"port1": { "port1": {
"name": "port1", "name": "port1",
@@ -40,9 +84,9 @@
} }
}, },
"control_button": { "control_button": {
"enabled": true, "enabled": false,
"gpio_chip": "/dev/gpiochip0", "gpio_chip": "/dev/gpiochip0",
"pin": 26, "pin": -1,
"active_low": true, "active_low": true,
"bias": "", "bias": "",
"debounce_ms": 50, "debounce_ms": 50,
@@ -140,29 +184,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "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": { "pass_through": {
"show_magnitude": true, "show_magnitude": true,
"show_phase": false, "show_phase": false,
"combo_filter": "",
"fixed_y_enabled": false, "fixed_y_enabled": false,
"y_min_db": -100.0, "y_min_db": -100.0,
"y_max_db": 0.0 "y_max_db": 0.0
@@ -226,11 +283,15 @@
"max_depth_m": 14.0, "max_depth_m": 14.0,
"range_comp_power": 0.28, "range_comp_power": 0.28,
"angle_comp_power": 0.1, "angle_comp_power": 0.1,
"score_mode": "combined",
"max_detected_objects_to_draw": 5,
"draw_top_m_objects": 2,
"start_freq_mhz": 3000.0, "start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0, "stop_freq_mhz": 6000.0,
"background_subtract_enabled": true, "background_subtract_enabled": true,
"background_mean_count": 10, "background_mean_count": 10,
"remove_sidelobe_objects_enabled": false, "remove_sidelobe_objects_enabled": false,
"imaging_plane_y_m": 0.0,
"render_mode": "heatmap", "render_mode": "heatmap",
"min_visible_score": 0.0, "min_visible_score": 0.0,
"visible_x_min_m": -2.0, "visible_x_min_m": -2.0,
@@ -248,7 +309,10 @@
"start_freq_mhz": 3000.0, "start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0, "stop_freq_mhz": 6000.0,
"speed_m_s": 0.0, "speed_m_s": 0.0,
"ignore_socket_speed_enabled": false,
"look_angle_deg": 0.0, "look_angle_deg": 0.0,
"apply_freq_phase_correction": true,
"reference_mode": "frame_center",
"snr_thresh": 4.5, "snr_thresh": 4.5,
"snr_comp_max": 25.0, "snr_comp_max": 25.0,
"background_subtract_enabled": true, "background_subtract_enabled": true,
@@ -269,7 +333,8 @@
"preprocess_dialog": { "preprocess_dialog": {
"set_name": "smoke_cal", "set_name": "smoke_cal",
"radar_config_dir": "", "radar_config_dir": "",
"use_all_radar_configs": false "use_all_radar_configs": false,
"median_sweep_count": 5
} }
} }
} }
@@ -6,17 +6,55 @@
"remote_port": 50209, "remote_port": 50209,
"driver_mode": "native", "driver_mode": "native",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [], "slave_serials": [],
"force_external_reference": false, "force_external_reference": false,
"recovery_attempts": 3 "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": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0 "stimulus_power_dbm": -10.0,
"points": 201
} }
}, },
"switches": { "switches": {
@@ -45,6 +83,15 @@
"invert_logic": false "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": { "run": {
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
@@ -137,29 +184,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "input_pos": 3,
"x_m": 0.185 "x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
} }
] ]
}, },
@@ -6,17 +6,55 @@
"remote_port": 50209, "remote_port": 50209,
"driver_mode": "native", "driver_mode": "native",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [], "slave_serials": [],
"force_external_reference": false, "force_external_reference": false,
"recovery_attempts": 3 "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": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0 "stimulus_power_dbm": -10.0,
"points": 201
} }
}, },
"switches": { "switches": {
@@ -45,6 +83,15 @@
"invert_logic": false "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": { "run": {
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
@@ -137,29 +184,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "input_pos": 3,
"x_m": 0.185 "x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
} }
] ]
}, },
@@ -2,8 +2,11 @@
"radar": { "radar": {
"model": "kamil_adc", "model": "kamil_adc",
"serial": "kamil_adc", "serial": "kamil_adc",
"remote_host": "127.0.0.1",
"remote_port": 50209,
"driver_mode": "native", "driver_mode": "native",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [], "slave_serials": [],
"force_external_reference": false, "force_external_reference": false,
@@ -79,6 +82,15 @@
"invert_logic": false "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": { "run": {
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
@@ -171,29 +183,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "input_pos": 3,
"x_m": 0.185 "x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
} }
] ]
}, },
@@ -2,19 +2,59 @@
"radar": { "radar": {
"model": "librevna", "model": "librevna",
"serial": "", "serial": "",
"remote_host": "127.0.0.1",
"remote_port": 50209,
"driver_mode": "native", "driver_mode": "native",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [], "slave_serials": [],
"force_external_reference": false, "force_external_reference": false,
"recovery_attempts": 3 "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": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0 "stimulus_power_dbm": -10.0,
"points": 201
} }
}, },
"switches": { "switches": {
@@ -43,6 +83,15 @@
"invert_logic": false "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": { "run": {
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
@@ -135,29 +184,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "input_pos": 3,
"x_m": 0.185 "x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
} }
] ]
}, },
@@ -2,8 +2,11 @@
"radar": { "radar": {
"model": "librevna_multi", "model": "librevna_multi",
"serial": "207730885532", "serial": "207730885532",
"remote_host": "127.0.0.1",
"remote_port": 50209,
"driver_mode": "native", "driver_mode": "native",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [ "slave_serials": [
"20A1307D5532", "20A1307D5532",
@@ -12,12 +15,49 @@
"force_external_reference": true, "force_external_reference": true,
"recovery_attempts": 3 "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": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0 "stimulus_power_dbm": -10.0,
"points": 201
} }
}, },
"switches": { "switches": {
@@ -46,6 +86,15 @@
"invert_logic": false "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": { "run": {
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
@@ -138,29 +187,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "input_pos": 3,
"x_m": 0.185 "x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
} }
] ]
}, },
@@ -2,19 +2,59 @@
"radar": { "radar": {
"model": "librevna", "model": "librevna",
"serial": "", "serial": "",
"remote_host": "127.0.0.1",
"remote_port": 50209,
"driver_mode": "mock", "driver_mode": "mock",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": { "multi_device": {
"slave_serials": [], "slave_serials": [],
"force_external_reference": false, "force_external_reference": false,
"recovery_attempts": 3 "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": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0 "stimulus_power_dbm": -10.0,
"points": 201
} }
}, },
"switches": { "switches": {
@@ -43,6 +83,15 @@
"invert_logic": false "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": { "run": {
"settling_ms": 0, "settling_ms": 0,
"idle_sleep_ms": 2, "idle_sleep_ms": 2,
@@ -135,29 +184,41 @@
"tx_geometry": [ "tx_geometry": [
{ {
"output_pos": 0, "output_pos": 0,
"x_m": 0.905 "x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"output_pos": 1, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
} }
], ],
"rx_geometry": [ "rx_geometry": [
{ {
"input_pos": 0, "input_pos": 0,
"x_m": -0.18 "x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 1, "input_pos": 1,
"x_m": 0.485 "x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 2, "input_pos": 2,
"x_m": -0.49 "x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
}, },
{ {
"input_pos": 3, "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": { "pass_through": {
"show_magnitude": true, "show_magnitude": true,
"show_phase": false, "show_phase": false,
"combo_filter": "",
"fixed_y_enabled": false, "fixed_y_enabled": false,
"y_min_db": -100.0, "y_min_db": -100.0,
"y_max_db": 0.0 "y_max_db": 0.0
@@ -221,11 +283,15 @@
"max_depth_m": 14.0, "max_depth_m": 14.0,
"range_comp_power": 0.28, "range_comp_power": 0.28,
"angle_comp_power": 0.1, "angle_comp_power": 0.1,
"score_mode": "combined",
"max_detected_objects_to_draw": 5,
"draw_top_m_objects": 2,
"start_freq_mhz": 3000.0, "start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0, "stop_freq_mhz": 6000.0,
"background_subtract_enabled": true, "background_subtract_enabled": true,
"background_mean_count": 10, "background_mean_count": 10,
"remove_sidelobe_objects_enabled": false, "remove_sidelobe_objects_enabled": false,
"imaging_plane_y_m": 0.0,
"render_mode": "heatmap", "render_mode": "heatmap",
"min_visible_score": 0.0, "min_visible_score": 0.0,
"visible_x_min_m": -2.0, "visible_x_min_m": -2.0,
@@ -243,7 +309,10 @@
"start_freq_mhz": 3000.0, "start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0, "stop_freq_mhz": 6000.0,
"speed_m_s": 0.0, "speed_m_s": 0.0,
"ignore_socket_speed_enabled": false,
"look_angle_deg": 0.0, "look_angle_deg": 0.0,
"apply_freq_phase_correction": true,
"reference_mode": "frame_center",
"snr_thresh": 4.5, "snr_thresh": 4.5,
"snr_comp_max": 25.0, "snr_comp_max": 25.0,
"background_subtract_enabled": true, "background_subtract_enabled": true,
@@ -264,7 +333,8 @@
"preprocess_dialog": { "preprocess_dialog": {
"set_name": "smoke_cal", "set_name": "smoke_cal",
"radar_config_dir": "", "radar_config_dir": "",
"use_all_radar_configs": false "use_all_radar_configs": false,
"median_sweep_count": 5
} }
} }
} }
-140
View File
@@ -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
}
}
}