Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7997abe2d9 | ||
|
|
8a52431bd3 | ||
|
|
cc6d189d52 | ||
|
|
6ada811c2f | ||
|
|
74723bb635 | ||
|
|
7c0ae1ecf8 | ||
|
|
b183401e6f | ||
|
|
d61b59b9a4 | ||
|
|
7c6cab07fc | ||
|
|
68bec25f17 |
+15
-3
@@ -21,8 +21,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -227,4 +227,16 @@ python_app/runtime
|
||||
SHARE_INTERNET_TO_PI.md
|
||||
|
||||
CLAUDE.md
|
||||
./docs
|
||||
/docs/
|
||||
test_end_2/
|
||||
|
||||
# --- device_firmware: PlatformIO / STM32G431 cart remote ---
|
||||
# build output (regenerated by `pio run`)
|
||||
device_firmware/cart_firmware/.pio/
|
||||
# scope captures: raw .bin + .npz + preview .png, local-only
|
||||
device_firmware/cart_firmware/captures/
|
||||
# machine-specific, regenerated by the PlatformIO extension
|
||||
device_firmware/cart_firmware/.vscode/c_cpp_properties.json
|
||||
device_firmware/cart_firmware/.vscode/launch.json
|
||||
device_firmware/cart_firmware/.vscode/ipch/
|
||||
device_firmware/cart_firmware/.vscode/.browse.c_cpp.db*
|
||||
@@ -38,6 +38,13 @@ struct SweepTraceBlock {
|
||||
std::vector<Complex32> s11{};
|
||||
// Complex S21 samples for matching frequency points.
|
||||
std::vector<Complex32> s21{};
|
||||
// Monotonic window in which THIS trace's sweep was measured, excluding the
|
||||
// switch drive and settling that preceded it. In a switched matrix the
|
||||
// collection is assembled combo by combo over many milliseconds, so the
|
||||
// collection-level window says nothing about when an individual combo was
|
||||
// measured. Zero on both is a valid "unmeasured" sentinel.
|
||||
std::uint64_t capture_start_ns = 0;
|
||||
std::uint64_t capture_end_ns = 0;
|
||||
};
|
||||
|
||||
struct RawSweepCollection {
|
||||
|
||||
@@ -172,6 +172,10 @@ void require_count_fits(std::uint32_t count, std::size_t min_bytes_each, BinaryR
|
||||
return trace;
|
||||
}
|
||||
|
||||
// The capture windows live in a TRAILER after the trace blocks rather than inside
|
||||
// them, so a reader built before they existed still decodes every trace and simply
|
||||
// stops early. The trailer grows the same way: collection window first, then the
|
||||
// per-trace window table (one pair per trace, in trace order).
|
||||
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
|
||||
writer.write(magic);
|
||||
writer.write(collection.collection_id);
|
||||
@@ -184,6 +188,12 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
|
||||
|
||||
writer.write(collection.capture_start_ns);
|
||||
writer.write(collection.capture_end_ns);
|
||||
|
||||
writer.write(checked_count_to_u32(collection.traces.size(), "Trace capture window count"));
|
||||
for (const auto& trace : collection.traces) {
|
||||
writer.write(trace.capture_start_ns);
|
||||
writer.write(trace.capture_end_ns);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto read_trace_collection(BinaryReader& reader, std::uint32_t expected_magic) -> RawSweepCollection {
|
||||
@@ -204,16 +214,31 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
|
||||
collection.traces.push_back(read_trace_block(reader));
|
||||
}
|
||||
|
||||
// Each trailer stage is optional: a payload from an older producer stops after
|
||||
// the trace blocks (or after the collection window) and leaves the rest zeroed.
|
||||
if (reader.remaining_bytes() == 0U) {
|
||||
return collection;
|
||||
}
|
||||
if (reader.remaining_bytes() != (sizeof(std::uint64_t) * 2U)) {
|
||||
throw std::runtime_error("Unexpected trailing bytes in trace collection");
|
||||
if (reader.remaining_bytes() < (sizeof(std::uint64_t) * 2U)) {
|
||||
throw std::runtime_error("Truncated capture window in trace collection");
|
||||
}
|
||||
|
||||
collection.capture_start_ns = reader.read<std::uint64_t>();
|
||||
collection.capture_end_ns = reader.read<std::uint64_t>();
|
||||
|
||||
if (reader.remaining_bytes() == 0U) {
|
||||
return collection;
|
||||
}
|
||||
|
||||
const auto trace_time_count = reader.read<std::uint32_t>();
|
||||
if (trace_time_count != collection.traces.size()) {
|
||||
throw std::runtime_error("Per-trace capture window count does not match trace count");
|
||||
}
|
||||
for (auto& trace : collection.traces) {
|
||||
trace.capture_start_ns = reader.read<std::uint64_t>();
|
||||
trace.capture_end_ns = reader.read<std::uint64_t>();
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ auto CalibrationMaster::apply_to_trace(const ipc::SweepTraceBlock& measured_trac
|
||||
output.frequency_hz = measured_trace.frequency_hz;
|
||||
output.s21 = apply_s21(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s21);
|
||||
output.s11 = apply_s11(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s11);
|
||||
// Calibration reshapes the samples, not when they were measured.
|
||||
output.capture_start_ns = measured_trace.capture_start_ns;
|
||||
output.capture_end_ns = measured_trace.capture_end_ns;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@ auto ReferenceMaster::apply_to_trace(const ipc::SweepTraceBlock& calibrated_trac
|
||||
output.frequency_hz = calibrated_trace.frequency_hz;
|
||||
output.s21 = apply_s21(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s21);
|
||||
output.s11 = apply_s11(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s11);
|
||||
// Reference subtraction reshapes the samples, not when they were measured.
|
||||
output.capture_start_ns = calibrated_trace.capture_start_ns;
|
||||
output.capture_end_ns = calibrated_trace.capture_end_ns;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,14 +147,18 @@ class DriverLifecycleGuard {
|
||||
// Worst-case serialized size of a collection given the configured combo count and sweep
|
||||
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
|
||||
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
|
||||
// + capture_start_ns(8) + capture_end_ns(8) = 40 bytes
|
||||
// + capture_start_ns(8) + capture_end_ns(8)
|
||||
// + trace capture window count(4) = 44 bytes
|
||||
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
|
||||
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
|
||||
// + trailer: capture_start_ns(8) + capture_end_ns(8) = 16 bytes
|
||||
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
|
||||
constexpr std::size_t kCollectionHeaderBytes = 40U;
|
||||
constexpr std::size_t kCollectionHeaderBytes = 44U;
|
||||
constexpr std::size_t kTraceHeaderBytes = 12U;
|
||||
constexpr std::size_t kBytesPerPoint = 20U;
|
||||
const std::size_t per_trace = kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint);
|
||||
constexpr std::size_t kTraceTrailerBytes = 16U;
|
||||
const std::size_t per_trace =
|
||||
kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint) + kTraceTrailerBytes;
|
||||
return kCollectionHeaderBytes + (combo_count * per_trace);
|
||||
}
|
||||
|
||||
@@ -290,7 +294,12 @@ auto SweepOrchestrator::acquire_one_collection(
|
||||
// Production drivers ignore this; mock drivers use it to give every
|
||||
// (input, output) pair its own synthetic response.
|
||||
radar_driver_.set_active_combo(combo);
|
||||
// Stamp around the sweep only: the switch drive and settling above belong to
|
||||
// neither the previous combo nor this one, so excluding them keeps the window
|
||||
// an honest "when was this combo actually measured".
|
||||
const auto sweep_start_ns = ipc::current_monotonic_ns();
|
||||
auto sweep = radar_driver_.acquire_sweep();
|
||||
const auto sweep_end_ns = ipc::current_monotonic_ns();
|
||||
validate_sweep(sweep);
|
||||
|
||||
ipc::SweepTraceBlock trace{};
|
||||
@@ -298,6 +307,8 @@ auto SweepOrchestrator::acquire_one_collection(
|
||||
trace.frequency_hz = std::move(sweep.frequency_hz);
|
||||
trace.s11 = std::move(sweep.s11);
|
||||
trace.s21 = std::move(sweep.s21);
|
||||
trace.capture_start_ns = sweep_start_ns;
|
||||
trace.capture_end_ns = sweep_end_ns;
|
||||
collection.traces.push_back(std::move(trace));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Протокол старого пульта тележки (реверс-инжиниринг)
|
||||
|
||||
Снято 2026-08-20 осциллографом Hantek DPO7204C с сигнального провода пульта
|
||||
(канал CH3). Инструменты: `tools/capture.py` (захват), `tools/decode.py`
|
||||
(декодер), сырые данные и картинки — в `captures/`.
|
||||
|
||||
## Физический уровень
|
||||
|
||||
- Один сигнальный провод, логика **3.3 В**.
|
||||
- **Инвертированный UART** (стандартная полярность SBUS): в покое линия
|
||||
**низкая** (~0 В), импульсы вверх до ~3.3 В.
|
||||
- Скорость **100 000 бод**, формат **8E2** (8 бит данных, чётность even,
|
||||
2 стоп-бита), биты LSB-first. Длительность бита 10 мкс.
|
||||
|
||||
## Кадровый уровень — SBUS
|
||||
|
||||
Стандартный кадр Futaba SBUS, 25 байт (3 мс на линии):
|
||||
|
||||
| Смещение | Размер | Содержимое |
|
||||
|---|---|---|
|
||||
| 0 | 1 | Заголовок `0x0F` |
|
||||
| 1 | 22 | 16 каналов × 11 бит, упакованы подряд LSB-first |
|
||||
| 23 | 1 | Флаги: bit0=CH17, bit1=CH18, bit2=frame_lost, bit3=failsafe |
|
||||
| 24 | 1 | Футер `0x00` |
|
||||
|
||||
Распаковка каналов: 22 байта складываются в 176-битное число LSB-first,
|
||||
канал N (N=0..15) = биты [11·N .. 11·N+10], диапазон значений 0–2047.
|
||||
|
||||
- Кадры отправляются каждые **50 мс** (20 Гц). Это медленнее стандартного
|
||||
SBUS (7/14 мс) — ответная часть с этим темпом работает.
|
||||
- Флаги во всех наблюдениях = `0x00`.
|
||||
|
||||
## Карта каналов (нумерация с 1)
|
||||
|
||||
| Управление | Канал | Мин | Нейтраль | Макс |
|
||||
|---|---|---|---|---|
|
||||
| Стик вперёд/назад | **2** | 433 (назад) | 1024 | 1643 (вперёд) |
|
||||
| Стик влево/вправо | **4** | 446 (влево) | 1024 | 1654 (вправо) |
|
||||
| Остальные 14 | — | всегда 1024 | | |
|
||||
|
||||
Диапазон осей ~±600 от нейтрали (не полная шкала SBUS). Других органов
|
||||
управления на пульте нет.
|
||||
|
||||
Эталонный кадр нейтрали (hex):
|
||||
|
||||
```
|
||||
0F 00 04 20 00 01 08 40 00 02 10 80 00 04 20 00 01 08 40 00 02 10 80 00 00
|
||||
```
|
||||
|
||||
## Воспроизведение на STM32G431
|
||||
|
||||
- USART: 100000 бод, 8 бит + чётность even (в терминах STM32: M=1, 9-bit
|
||||
с PCE=1), 2 стоп-бита, **TXINV=1** (аппаратная инверсия TX) — бит-бэнг
|
||||
не нужен.
|
||||
- Отправлять 25-байтовый кадр по таймеру каждые 50 мс.
|
||||
- Нейтраль: все каналы 1024; управление — каналы 2 и 4 в измеренных
|
||||
диапазонах.
|
||||
@@ -0,0 +1,10 @@
|
||||
[env:weact_g431cb]
|
||||
platform = ststm32
|
||||
board = genericSTM32G431CB
|
||||
framework = arduino
|
||||
upload_protocol = stlink
|
||||
debug_tool = stlink
|
||||
monitor_speed = 115200
|
||||
build_flags =
|
||||
-DUSBCON
|
||||
-DUSBD_USE_CDC
|
||||
@@ -0,0 +1,166 @@
|
||||
// Пульт тележки: стик (АЦП PA0/PA1) -> SBUS на USART1 TX (PA9).
|
||||
// Протокол: инвертированный SBUS, 100000 бод 8E2, 25 байт каждые 50 мс
|
||||
// (см. docs/protocol.md). USB CDC (Serial) — отладочный вывод.
|
||||
#include <Arduino.h>
|
||||
|
||||
// ---- калибровка стика (АЦП 12 бит, замерено 2026-08-20) ----
|
||||
static const int X_FWD = 650, X_MID = 2014, X_BACK = 3378; // PA0 (плечи равны: 2014-650 = 3378-2014 = 1364)
|
||||
static const int Y_RIGHT = 479, Y_MID = 1981, Y_LEFT = 3586; // PA1
|
||||
static const int DEADZONE = 15; // отсечка дребезга вокруг нейтрали
|
||||
|
||||
// ---- SBUS-значения старого пульта ----
|
||||
static const uint16_t SBUS_MID = 1024;
|
||||
static const uint16_t CH2_MIN = 433, CH2_MAX = 1643; // назад..вперёд
|
||||
static const uint16_t CH4_MIN = 446, CH4_MAX = 1654; // влево..вправо
|
||||
|
||||
// ---- expo: 0 = линейно, 1 = максимально мягкая нейтраль ----
|
||||
static const float EXPO_K = 0.0f;
|
||||
// ---- общий масштаб выхода: 1.0 = диапазон старого пульта ----
|
||||
static const float RANGE_SCALE = 0.75f;
|
||||
|
||||
// симметричные плечи: вперёд и назад дают одинаковый максимум
|
||||
static const uint16_t CH2_SPAN = 591; // min(1643-1024, 1024-433)
|
||||
static const uint16_t CH4_SPAN = 578; // min(1654-1024, 1024-446)
|
||||
|
||||
// ---- профиль газа/поворота ----
|
||||
static const float MOVE_START = 0.15f; // старт движения, доля хода стика
|
||||
static const float POWER_FWD = 0.60f; // потолок «вперёд»
|
||||
static const float POWER_BACK = 0.50f; // потолок «назад»
|
||||
static const float POWER_TURN = 0.70f; // потолок поворота
|
||||
static const uint16_t CH2_DB = 221; // мёртвая зона приёмника тележки (ЗАМЕРИТЬ по монитору)
|
||||
static const uint16_t CH4_DB = 221;
|
||||
|
||||
static const uint32_t FRAME_PERIOD_MS = 50;
|
||||
static const uint32_t PIN_X = PA0;
|
||||
static const uint32_t PIN_Y = PA1;
|
||||
|
||||
static UART_HandleTypeDef s_sbusUart;
|
||||
|
||||
// USART1 TX = PA9 (AF7), 100000 бод, 8E2, TX инвертирован
|
||||
static void sbusUartInit() {
|
||||
__HAL_RCC_GPIOA_CLK_ENABLE();
|
||||
__HAL_RCC_USART1_CLK_ENABLE();
|
||||
|
||||
GPIO_InitTypeDef gpio = {};
|
||||
gpio.Pin = GPIO_PIN_9;
|
||||
gpio.Mode = GPIO_MODE_AF_PP;
|
||||
gpio.Pull = GPIO_NOPULL;
|
||||
gpio.Speed = GPIO_SPEED_FREQ_LOW;
|
||||
gpio.Alternate = GPIO_AF7_USART1;
|
||||
HAL_GPIO_Init(GPIOA, &gpio);
|
||||
|
||||
s_sbusUart.Instance = USART1;
|
||||
s_sbusUart.Init.BaudRate = 100000;
|
||||
s_sbusUart.Init.WordLength = UART_WORDLENGTH_9B; // 8 данных + чётность
|
||||
s_sbusUart.Init.StopBits = UART_STOPBITS_2;
|
||||
s_sbusUart.Init.Parity = UART_PARITY_EVEN;
|
||||
s_sbusUart.Init.Mode = UART_MODE_TX;
|
||||
s_sbusUart.Init.HwFlowCtl = UART_HWCONTROL_NONE;
|
||||
s_sbusUart.Init.OverSampling = UART_OVERSAMPLING_16;
|
||||
s_sbusUart.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_TXINVERT_INIT;
|
||||
s_sbusUart.AdvancedInit.TxPinLevelInvert = UART_ADVFEATURE_TXINV_ENABLE;
|
||||
HAL_UART_Init(&s_sbusUart);
|
||||
}
|
||||
|
||||
// нормализация одной оси в [-1..1] с мёртвой зоной и асимметричными плечами
|
||||
static float axisNorm(int adc, int lowEnd, int mid, int highEnd) {
|
||||
float x;
|
||||
if (adc < mid - DEADZONE) {
|
||||
x = (float)(mid - adc) / (float)(mid - lowEnd); // к lowEnd -> +1
|
||||
} else if (adc > mid + DEADZONE) {
|
||||
x = -(float)(adc - mid) / (float)(highEnd - mid); // к highEnd -> -1
|
||||
} else {
|
||||
return 0.0f;
|
||||
}
|
||||
return constrain(x, -1.0f, 1.0f);
|
||||
}
|
||||
|
||||
// expo-кривая: гасит чувствительность у нейтрали, сохраняет края
|
||||
static float expo(float x) {
|
||||
return EXPO_K * x * x * x + (1.0f - EXPO_K) * x;
|
||||
}
|
||||
|
||||
static uint16_t toSbus(float x, uint16_t span) {
|
||||
return (uint16_t)(SBUS_MID + lroundf(x * span));
|
||||
}
|
||||
|
||||
// стик [-1..1] -> SBUS-значение канала: ниже MOVE_START — нейтраль, выше —
|
||||
// линейно от порога срабатывания приёмника (db) до POWER_FRAC*span на полном стике
|
||||
static uint16_t driveToSbus(float x, uint16_t span, uint16_t db,
|
||||
float powerPos, float powerNeg) {
|
||||
float mag = fabsf(x);
|
||||
if (mag <= MOVE_START)
|
||||
return SBUS_MID;
|
||||
float outMax = (x > 0.0f ? powerPos : powerNeg) * span;
|
||||
float t = (mag - MOVE_START) / (1.0f - MOVE_START);
|
||||
long delta = lroundf(db + t * (outMax - db));
|
||||
return (x > 0.0f) ? (uint16_t)(SBUS_MID + delta)
|
||||
: (uint16_t)(SBUS_MID - delta);
|
||||
}
|
||||
|
||||
static void sbusPack(uint8_t out[25], const uint16_t ch[16]) {
|
||||
out[0] = 0x0F;
|
||||
memset(out + 1, 0, 22);
|
||||
uint32_t bitpos = 0;
|
||||
for (int n = 0; n < 16; n++) {
|
||||
for (int b = 0; b < 11; b++) {
|
||||
if (ch[n] & (1u << b))
|
||||
out[1 + (bitpos >> 3)] |= 1u << (bitpos & 7);
|
||||
bitpos++;
|
||||
}
|
||||
}
|
||||
out[23] = 0x00; // флаги
|
||||
out[24] = 0x00; // футер
|
||||
}
|
||||
|
||||
static int readAvg(uint32_t pin) {
|
||||
uint32_t acc = 0;
|
||||
for (int i = 0; i < 16; i++)
|
||||
acc += analogRead(pin);
|
||||
return acc / 16;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
analogReadResolution(12);
|
||||
pinMode(PIN_X, INPUT_ANALOG);
|
||||
pinMode(PIN_Y, INPUT_ANALOG);
|
||||
sbusUartInit();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
static uint32_t next = 0;
|
||||
uint32_t now = millis();
|
||||
if (now < next)
|
||||
return;
|
||||
next = now + FRAME_PERIOD_MS;
|
||||
|
||||
int adcX = readAvg(PIN_X);
|
||||
int adcY = readAvg(PIN_Y);
|
||||
// вперёд = adcX к X_FWD (вниз) -> +1; вправо = adcY к Y_RIGHT -> +1
|
||||
float fwd = axisNorm(adcX, X_FWD, X_MID, X_BACK);
|
||||
float right = axisNorm(adcY, Y_RIGHT, Y_MID, Y_LEFT);
|
||||
|
||||
uint16_t ch[16];
|
||||
for (int i = 0; i < 16; i++)
|
||||
ch[i] = SBUS_MID;
|
||||
ch[1] = driveToSbus(fwd, CH2_SPAN, CH2_DB, POWER_FWD, POWER_BACK); // канал 2 — газ
|
||||
ch[3] = driveToSbus(right, CH4_SPAN, CH4_DB, POWER_TURN, POWER_TURN); // канал 4 — поворот
|
||||
|
||||
uint8_t frame[25];
|
||||
sbusPack(frame, ch);
|
||||
HAL_UART_Transmit(&s_sbusUart, frame, sizeof(frame), 20);
|
||||
|
||||
Serial.print("adc=");
|
||||
Serial.print(adcX);
|
||||
Serial.print(",");
|
||||
Serial.print(adcY);
|
||||
Serial.print(" fwd=");
|
||||
Serial.print(fwd, 3);
|
||||
Serial.print(" right=");
|
||||
Serial.print(right, 3);
|
||||
Serial.print(" ch2=");
|
||||
Serial.print(ch[1]);
|
||||
Serial.print(" ch4=");
|
||||
Serial.println(ch[3]);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze a captured frame: extract pulse timing structure.
|
||||
|
||||
Usage: .venv/bin/python tools/analyze.py captures/<name>.npz
|
||||
"""
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
path = sys.argv[1]
|
||||
d = np.load(path)
|
||||
volts = d["volts"]
|
||||
srate = float(d["srate"])
|
||||
dt_us = 1e6 / srate
|
||||
|
||||
# threshold midway between the two dominant plateaus
|
||||
hi_level = np.median(volts) # idle dominates the frame -> median = idle (high)
|
||||
lo_level = np.percentile(volts, 10)
|
||||
thr = (hi_level + lo_level) / 2
|
||||
bits = (volts > thr).astype(np.int8)
|
||||
print(f"levels: high~{hi_level:.2f} low~{lo_level:.2f} thr={thr:.2f} (raw units)")
|
||||
|
||||
# run-length encode
|
||||
edges = np.flatnonzero(np.diff(bits)) + 1
|
||||
starts = np.concatenate(([0], edges))
|
||||
ends = np.concatenate((edges, [len(bits)]))
|
||||
levels = bits[starts]
|
||||
dur_us = (ends - starts) * dt_us
|
||||
|
||||
print(f"{len(levels)} runs total")
|
||||
print("\nidx level dur_us (first/last runs are idle padding)")
|
||||
for i, (lv, du) in enumerate(zip(levels, dur_us)):
|
||||
tag = "H" if lv else "L"
|
||||
print(f"{i:4d} {tag} {du:10.2f}")
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture one single-shot CH3 frame from Hantek DPO7204C, save raw + PNG.
|
||||
|
||||
Flow: query settings -> :SINGle -> poll :TRIGger:STATus? until STOP ->
|
||||
WAVeform:DATA:ALL? CHANnel3 (drained completely, multi-packet aware).
|
||||
Scope is left in STOP so the on-screen frame matches the saved data.
|
||||
|
||||
Usage: .venv/bin/python tools/capture.py <name> [device]
|
||||
Saves captures/<name>.bin, captures/<name>.npz, captures/<name>.png
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
DEV = sys.argv[2] if len(sys.argv) > 2 else "/dev/usbtmc2"
|
||||
NAME = sys.argv[1] if len(sys.argv) > 1 else "capture"
|
||||
OUTDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "captures")
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
|
||||
FIRST_HDR = 11 + 117 # '#9'+9-digit len, then 18 bytes counters + 99 bytes info
|
||||
NEXT_HDR = 11 + 18 # follow-up packets: counters only
|
||||
|
||||
|
||||
def read_exact(fd, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = os.read(fd, min(1 << 20, n - len(buf)))
|
||||
if not chunk:
|
||||
raise IOError("short read from scope")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def read_packet(fd):
|
||||
head = read_exact(fd, 11)
|
||||
assert head[:2] == b"#9", f"bad packet start: {head!r}"
|
||||
pkt_len = int(head[2:11])
|
||||
body = read_exact(fd, pkt_len)
|
||||
return head + body
|
||||
|
||||
|
||||
def query(fd, cmd):
|
||||
os.write(fd, cmd.encode() + b"\n")
|
||||
return os.read(fd, 256).decode(errors="replace").strip()
|
||||
|
||||
|
||||
fd = os.open(DEV, os.O_RDWR)
|
||||
print("IDN:", query(fd, "*IDN?"))
|
||||
|
||||
tdiv = float(query(fd, ":TIMebase:SCALe?"))
|
||||
vdiv = float(query(fd, ":CHANnel3:SCALe?"))
|
||||
voff = float(query(fd, ":CHANnel3:OFFSet?"))
|
||||
print(f"tdiv={tdiv} s/div vdiv={vdiv} V/div offset={voff} V")
|
||||
|
||||
os.write(fd, b":SINGle\n")
|
||||
for _ in range(100):
|
||||
time.sleep(0.1)
|
||||
st = query(fd, ":TRIGger:STATus?")
|
||||
if st == "STOP":
|
||||
break
|
||||
else:
|
||||
sys.exit(f"scope did not reach STOP (last status: {st})")
|
||||
print("status: STOP (frame captured)")
|
||||
|
||||
srate = float(query(fd, ":ACQuire:SRATe?"))
|
||||
print(f"srate={srate:.3e} Sa/s")
|
||||
|
||||
os.write(fd, b"WAVeform:DATA:ALL? CHANnel3\n")
|
||||
pkt = read_packet(fd)
|
||||
total_len = int(pkt[11:20])
|
||||
info_hdr = pkt[29:FIRST_HDR]
|
||||
data = pkt[FIRST_HDR:]
|
||||
raw_all = pkt
|
||||
while len(data) < total_len:
|
||||
os.write(fd, b"WAVeform:DATA:ALL? CHANnel3\n")
|
||||
p = read_packet(fd)
|
||||
raw_all += p
|
||||
data += p[NEXT_HDR:]
|
||||
print(f"received {len(data)} samples (declared {total_len})")
|
||||
print("info header hex:", info_hdr.hex(" "))
|
||||
os.close(fd)
|
||||
|
||||
raw = np.frombuffer(data[:total_len], dtype=np.uint8).astype(np.float32)
|
||||
# unsigned 8-bit, 25.6 levels/div, mid-screen = code 128, offset shifts zero
|
||||
volts = (raw - 128.0) / 25.6 * vdiv - voff
|
||||
t = np.arange(len(volts)) / srate * 1e3 # ms
|
||||
|
||||
base = os.path.join(OUTDIR, NAME)
|
||||
with open(base + ".bin", "wb") as f:
|
||||
f.write(raw_all)
|
||||
np.savez(base + ".npz", volts=volts, srate=srate, vdiv=vdiv, voff=voff, tdiv=tdiv)
|
||||
|
||||
fig, axes = plt.subplots(2, 1, figsize=(16, 8))
|
||||
axes[0].plot(t, volts, lw=0.5)
|
||||
axes[0].set_title(f"{NAME} — full frame ({srate:.0e} Sa/s, {vdiv} V/div, off {voff} V)")
|
||||
# zoom on activity: region where signal deviates from its median
|
||||
dev_idx = np.where(np.abs(volts - np.median(volts)) > 0.5)[0]
|
||||
if len(dev_idx):
|
||||
lo = max(0, dev_idx[0] - int(0.05 * (dev_idx[-1] - dev_idx[0] + 1)) - 100)
|
||||
hi = min(len(volts), dev_idx[-1] + int(0.05 * (dev_idx[-1] - dev_idx[0] + 1)) + 100)
|
||||
axes[1].plot(t[lo:hi], volts[lo:hi], lw=0.7)
|
||||
axes[1].set_title("zoom on activity")
|
||||
for ax in axes:
|
||||
ax.set_xlabel("t, ms")
|
||||
ax.set_ylabel("U, V")
|
||||
ax.grid(True, alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(base + ".png", dpi=110)
|
||||
print("saved:", base + ".png")
|
||||
print(f"range: min={volts.min():.3f} V max={volts.max():.3f} V median={np.median(volts):.3f} V")
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rigorous comparison of two SBUS captures (old remote vs our firmware).
|
||||
|
||||
Usage: .venv/bin/python tools/compare.py captures/a.npz captures/b.npz
|
||||
"""
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def extract(path):
|
||||
d = np.load(path)
|
||||
v = d["volts"]
|
||||
srate = float(d["srate"])
|
||||
idle = np.median(v)
|
||||
p10, p90 = np.percentile(v, [10, 90])
|
||||
active = p10 if abs(p10 - idle) > abs(p90 - idle) else p90
|
||||
thr = (idle + active) / 2
|
||||
phys_hi = v > thr # physical high (pulse)
|
||||
# plateau levels: median of samples well inside each state
|
||||
lvl_hi = np.median(v[phys_hi])
|
||||
lvl_lo = np.median(v[~phys_hi])
|
||||
# runs
|
||||
sig = phys_hi.astype(np.int8)
|
||||
edges = np.flatnonzero(np.diff(sig)) + 1
|
||||
starts = np.concatenate(([0], edges))
|
||||
ends = np.concatenate((edges, [len(sig)]))
|
||||
levels = sig[starts]
|
||||
dur_us = (ends - starts) * 1e6 / srate
|
||||
# burst envelope: first to last physical-high sample
|
||||
hi_idx = np.flatnonzero(phys_hi)
|
||||
envelope_us = (hi_idx[-1] - hi_idx[0] + 1) * 1e6 / srate
|
||||
# inner runs (drop leading/trailing idle)
|
||||
runs = [(int(l), float(du)) for l, du in zip(levels[1:-1], dur_us[1:-1])]
|
||||
# UART logic: logic1 == idle state; idle here is physical low
|
||||
stream = []
|
||||
for l, du in runs:
|
||||
n = max(1, round(du / 10.0))
|
||||
stream.extend([1 - l] * n) # physical high -> logic 0
|
||||
stream.extend([1] * 24)
|
||||
frames = []
|
||||
i = 0
|
||||
while i + 12 <= len(stream):
|
||||
if stream[i] == 1:
|
||||
i += 1
|
||||
continue
|
||||
byte = sum(b << k for k, b in enumerate(stream[i + 1 : i + 9]))
|
||||
frames.append(byte)
|
||||
i += 12
|
||||
# bit clock estimate: envelope should be 299 bits (last stop bits merge w/ idle)
|
||||
n_units = round(envelope_us / 10.0)
|
||||
bit_us = envelope_us / n_units
|
||||
return {
|
||||
"lvl_hi": lvl_hi,
|
||||
"lvl_lo": lvl_lo,
|
||||
"runs": runs,
|
||||
"frames": frames,
|
||||
"envelope_us": envelope_us,
|
||||
"bit_us": bit_us,
|
||||
"vmin": float(v.min()),
|
||||
"vmax": float(v.max()),
|
||||
}
|
||||
|
||||
|
||||
a_path, b_path = sys.argv[1], sys.argv[2]
|
||||
A, B = extract(a_path), extract(b_path)
|
||||
|
||||
print(f"{'':24s} {'A: ' + a_path:>28s} {'B: ' + b_path:>28s}")
|
||||
print(f"{'bytes decoded':24s} {len(A['frames']):>28d} {len(B['frames']):>28d}")
|
||||
ha = " ".join(f"{x:02X}" for x in A["frames"])
|
||||
hb = " ".join(f"{x:02X}" for x in B["frames"])
|
||||
print(f"frames identical: {A['frames'] == B['frames']}")
|
||||
print(" A:", ha)
|
||||
print(" B:", hb)
|
||||
print(f"{'run count':24s} {len(A['runs']):>28d} {len(B['runs']):>28d}")
|
||||
qa = [round(du / 10) for _, du in A["runs"]]
|
||||
qb = [round(du / 10) for _, du in B["runs"]]
|
||||
la = [l for l, _ in A["runs"]]
|
||||
lb = [l for l, _ in B["runs"]]
|
||||
print(f"quantized run pattern identical: {qa == qb and la == lb}")
|
||||
print(f"{'envelope, us':24s} {A['envelope_us']:>28.2f} {B['envelope_us']:>28.2f}")
|
||||
print(f"{'bit time, us':24s} {A['bit_us']:>28.4f} {B['bit_us']:>28.4f}")
|
||||
print(f"{'-> baud':24s} {1e6/A['bit_us']:>28.1f} {1e6/B['bit_us']:>28.1f}")
|
||||
print(f"{'high plateau, V':24s} {A['lvl_hi']:>28.3f} {B['lvl_hi']:>28.3f}")
|
||||
print(f"{'low plateau, V':24s} {A['lvl_lo']:>28.3f} {B['lvl_lo']:>28.3f}")
|
||||
print(f"{'abs min/max, V':24s} {A['vmin']:>14.2f}/{A['vmax']:>12.2f} {B['vmin']:>14.2f}/{B['vmax']:>12.2f}")
|
||||
|
||||
# worst run deviation from ideal 10us grid
|
||||
da = max(abs(du - 10 * round(du / 10)) for _, du in A["runs"])
|
||||
db = max(abs(du - 10 * round(du / 10)) for _, du in B["runs"])
|
||||
print(f"{'worst grid dev, us':24s} {da:>28.2f} {db:>28.2f}")
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a captured frame as SBUS: UART 100 kbit/s 8E2, 25-byte frame,
|
||||
16 channels x 11 bits.
|
||||
|
||||
Usage: .venv/bin/python tools/decode.py captures/<name>.npz
|
||||
"""
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
BIT_US = 10.0
|
||||
|
||||
path = sys.argv[1]
|
||||
d = np.load(path)
|
||||
volts = d["volts"]
|
||||
srate = float(d["srate"])
|
||||
dt_us = 1e6 / srate
|
||||
|
||||
# idle level dominates the record; UART logic 1 == idle regardless of
|
||||
# physical polarity (this line is standard inverted SBUS: idle low)
|
||||
idle = np.median(volts)
|
||||
p10, p90 = np.percentile(volts, [10, 90])
|
||||
active = p10 if abs(p10 - idle) > abs(p90 - idle) else p90
|
||||
thr = (idle + active) / 2
|
||||
if active > idle:
|
||||
sig = (volts < thr).astype(np.int8) # pulses up -> logic 0
|
||||
else:
|
||||
sig = (volts > thr).astype(np.int8)
|
||||
|
||||
edges = np.flatnonzero(np.diff(sig)) + 1
|
||||
starts = np.concatenate(([0], edges))
|
||||
ends = np.concatenate((edges, [len(sig)]))
|
||||
levels = sig[starts]
|
||||
dur_us = (ends - starts) * dt_us
|
||||
|
||||
stream = []
|
||||
for lv, du in zip(levels[1:-1], dur_us[1:-1]):
|
||||
stream.extend([int(lv)] * max(1, round(du / BIT_US)))
|
||||
# trailing idle of last stop bits is trimmed by run cut; pad with idle-high
|
||||
stream.extend([1] * 24)
|
||||
|
||||
# deframe 8E2: start=0, 8 data LSB-first, even parity, 2 stop=1
|
||||
i = 0
|
||||
frames = []
|
||||
errors = []
|
||||
while i + 12 <= len(stream):
|
||||
if stream[i] == 1:
|
||||
i += 1
|
||||
continue
|
||||
data = stream[i + 1 : i + 9]
|
||||
par = stream[i + 9]
|
||||
stops = stream[i + 10 : i + 12]
|
||||
byte = sum(b << k for k, b in enumerate(data))
|
||||
if par != (sum(data) & 1):
|
||||
errors.append((len(frames), "parity"))
|
||||
if stops != [1, 1]:
|
||||
errors.append((len(frames), f"stop={stops}"))
|
||||
frames.append(byte)
|
||||
i += 12
|
||||
|
||||
print(f"decoded {len(frames)} bytes, errors: {errors if errors else 'none'}")
|
||||
print("hex:", " ".join(f"{b:02X}" for b in frames))
|
||||
|
||||
if len(frames) >= 25 and frames[0] == 0x0F:
|
||||
payload = frames[1:23]
|
||||
flags = frames[23]
|
||||
footer = frames[24]
|
||||
bits = 0
|
||||
for k, b in enumerate(payload):
|
||||
bits |= b << (8 * k)
|
||||
ch = [(bits >> (11 * n)) & 0x7FF for n in range(16)]
|
||||
print("\nSBUS frame OK" if footer == 0x00 else f"\nfooter unexpected: {footer:02X}")
|
||||
print("channels:", ch)
|
||||
print(f"flags: 0x{flags:02X} (bit0=ch17 bit1=ch18 bit2=frame_lost bit3=failsafe)")
|
||||
else:
|
||||
print("not a valid SBUS frame (no 0x0F header)")
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal SCPI helper for Hantek DPO7204C over /dev/usbtmc2."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
DEV = "/dev/usbtmc2"
|
||||
|
||||
|
||||
class Scope:
|
||||
def __init__(self, dev=DEV):
|
||||
self.fd = os.open(dev, os.O_RDWR)
|
||||
|
||||
def write(self, cmd: str):
|
||||
os.write(self.fd, cmd.encode() + b"\n")
|
||||
|
||||
def read(self, n=1 << 20, timeout=3.0) -> bytes:
|
||||
# usbtmc read returns one transfer chunk; loop until short read
|
||||
chunks = []
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
try:
|
||||
data = os.read(self.fd, n)
|
||||
except OSError:
|
||||
break
|
||||
chunks.append(data)
|
||||
if not data or len(data) < n:
|
||||
break
|
||||
return b"".join(chunks)
|
||||
|
||||
def query(self, cmd: str, timeout=3.0) -> str:
|
||||
self.write(cmd)
|
||||
return self.read(timeout=timeout).decode(errors="replace").strip()
|
||||
|
||||
def query_raw(self, cmd: str, timeout=5.0) -> bytes:
|
||||
self.write(cmd)
|
||||
return self.read(timeout=timeout)
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
s = Scope()
|
||||
for cmd in sys.argv[1:]:
|
||||
if cmd.endswith("?"):
|
||||
print(f"{cmd:40s} -> {s.query(cmd)}")
|
||||
else:
|
||||
s.write(cmd)
|
||||
print(f"{cmd:40s} [sent]")
|
||||
s.close()
|
||||
+125
-47
@@ -15,9 +15,10 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
@@ -58,33 +59,60 @@ def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]
|
||||
return {"panel_details": details, "panel_once_key": once_key}
|
||||
|
||||
|
||||
class _PanelLogBridge(QObject):
|
||||
"""Marshals log records from any thread onto the GUI thread for panel rendering.
|
||||
class _PanelLogBuffer:
|
||||
"""Thread-safe bounded buffer between logging handlers and the GUI flush timer.
|
||||
|
||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
|
||||
but the log widget may only be touched on the GUI thread; emitting this queued
|
||||
signal hands the record across safely (the GPIO-button pattern).
|
||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster)
|
||||
at a very high rate — e.g. the USB RX threads while the free-running sweep
|
||||
streams. Posting one queued Qt event per record used to flood the GUI event
|
||||
queue and keep the interface frozen long after a blocking operation finished
|
||||
while the backlog rendered. Instead, records land in this bounded buffer and
|
||||
a periodic GUI-side timer drains them in one batch; overflow drops the oldest
|
||||
records and reports how many were lost.
|
||||
"""
|
||||
|
||||
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
|
||||
_CAPACITY = 2000
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY)
|
||||
self._dropped_count = 0
|
||||
|
||||
def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None:
|
||||
"""Store one record, evicting the oldest when full (any thread)."""
|
||||
with self._lock:
|
||||
if len(self._entries) == self._CAPACITY:
|
||||
self._dropped_count += 1
|
||||
self._entries.append((level, text, details, once_key))
|
||||
|
||||
def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]:
|
||||
"""Return and clear all buffered records plus the overflow-drop count."""
|
||||
with self._lock:
|
||||
entries = list(self._entries)
|
||||
self._entries.clear()
|
||||
dropped_count = self._dropped_count
|
||||
self._dropped_count = 0
|
||||
return entries, dropped_count
|
||||
|
||||
|
||||
class _QtLogPanelHandler(logging.Handler):
|
||||
"""Logging handler that forwards application log records to the GUI log panel."""
|
||||
|
||||
def __init__(self, bridge: _PanelLogBridge) -> None:
|
||||
def __init__(self, buffer: _PanelLogBuffer) -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
self._buffer = buffer
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
|
||||
"""Buffer one record for the panel, mapping WARNING to the short 'WARN'."""
|
||||
try:
|
||||
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
|
||||
self._bridge.record.emit(
|
||||
details = getattr(record, "panel_details", None)
|
||||
once_key = getattr(record, "panel_once_key", None)
|
||||
self._buffer.append(
|
||||
display_level,
|
||||
record.getMessage(),
|
||||
getattr(record, "panel_details", None),
|
||||
getattr(record, "panel_once_key", None),
|
||||
details if isinstance(details, str) else None,
|
||||
once_key if isinstance(once_key, str) else None,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - logging must never raise into the caller
|
||||
self.handleError(record)
|
||||
@@ -145,23 +173,53 @@ class AppWindow(
|
||||
log_dir = self._project_root / "python_app/runtime/logs"
|
||||
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
|
||||
self._gui_logger = get_logger("gui")
|
||||
self._log_panel_bridge = _PanelLogBridge()
|
||||
self._log_panel_bridge.record.connect(self._on_log_record)
|
||||
self._log_panel_buffer = _PanelLogBuffer()
|
||||
|
||||
def _attach_log_panel(self) -> None:
|
||||
"""Route application log records into the on-screen panel (widget now exists)."""
|
||||
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
|
||||
add_handler(_QtLogPanelHandler(self._log_panel_buffer))
|
||||
# One bounded flush per tick instead of one queued event per record: the
|
||||
# panel can never flood the GUI event queue, no matter how chatty a
|
||||
# DEBUG-level driver gets.
|
||||
self._log_flush_timer = QTimer(self)
|
||||
self._log_flush_timer.setInterval(100)
|
||||
self._log_flush_timer.timeout.connect(self._flush_log_panel_buffer)
|
||||
self._log_flush_timer.start()
|
||||
|
||||
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
|
||||
"""Render one forwarded log record in the panel (always on the GUI thread)."""
|
||||
if not hasattr(self, "_log_box"):
|
||||
def _flush_log_panel_buffer(self) -> None:
|
||||
"""Render every buffered log record into the panel as one batched insert."""
|
||||
entries, dropped_count = self._log_panel_buffer.drain()
|
||||
if (not entries and not dropped_count) or not hasattr(self, "_log_box"):
|
||||
return
|
||||
self._append_log_entry(
|
||||
level,
|
||||
text,
|
||||
details=details if isinstance(details, str) else None,
|
||||
once_key=once_key if isinstance(once_key, str) else None,
|
||||
|
||||
entry_htmls: list[str] = []
|
||||
if dropped_count:
|
||||
entry_htmls.append(
|
||||
self._render_log_entry_html(
|
||||
"WARN",
|
||||
f"Log panel overflow: {dropped_count} record(s) dropped "
|
||||
"(they are still in the log file).",
|
||||
)
|
||||
)
|
||||
error_seen = False
|
||||
for level, text, details, once_key in entries:
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
continue
|
||||
self._logged_once_keys.add(once_key)
|
||||
entry_htmls.append(self._render_log_entry_html(level, text, details))
|
||||
error_seen = error_seen or level.upper() == "ERROR"
|
||||
|
||||
if not entry_htmls:
|
||||
return
|
||||
cursor = self._log_box.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self._log_box.setTextCursor(cursor)
|
||||
self._log_box.insertHtml("".join(entry_htmls))
|
||||
self._log_box.insertPlainText("\n")
|
||||
self._log_box.ensureCursorVisible()
|
||||
if error_seen and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
def _init_runtime_services(self) -> None:
|
||||
"""Initialize long-lived service objects used by mixins."""
|
||||
@@ -266,6 +324,10 @@ class AppWindow(
|
||||
def _init_capture_state(self) -> None:
|
||||
"""Initialize one-shot capture and sequence-control flags."""
|
||||
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
|
||||
# Guards the blocking per-combo capture against duplicate requests, and keeps
|
||||
# the dialog's action buttons disabled until the post-capture input backlog
|
||||
# is dropped (see AppWindowPreprocessMixin._begin/_end_preprocess_capture).
|
||||
self._preprocess_capture_busy = False
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns: int | None = None
|
||||
@@ -274,7 +336,7 @@ class AppWindow(
|
||||
|
||||
def _init_history_state(self) -> None:
|
||||
"""Initialize runtime history buffers and render-cache state."""
|
||||
bscan_history_limit = self._history_limit_from_config()
|
||||
bscan_cpp_replay_window = self._cpp_bscan_replay_window_from_config()
|
||||
save_history_limit = self._save_history_limit_from_config()
|
||||
self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
|
||||
self._pre_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
|
||||
@@ -282,11 +344,21 @@ class AppWindow(
|
||||
|
||||
# Sequence id must survive GUI restarts so history commands stay monotonic.
|
||||
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
|
||||
self._bscan_history_limit = bscan_history_limit
|
||||
self._bscan_cpp_replay_window = bscan_cpp_replay_window
|
||||
self._bscan_history_by_combo = {}
|
||||
self._bscan_depth_axis_by_combo = {}
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._bscan_render_signature = None
|
||||
# Writer into the processor's input ring, used to re-feed retained sweeps so the
|
||||
# whole visible B-scan is recomputed. Opened lazily, only while acquisition is
|
||||
# stopped (see AppWindowBscanReplayMixin).
|
||||
self._replay_ring_writer = None
|
||||
self._bscan_replay_active = False
|
||||
# Results from the last completed replay. While non-empty they, not
|
||||
# `_result_history`, are what the B-scan renders (see AppWindowBscanReplayMixin).
|
||||
self._bscan_replay_results = []
|
||||
self._bscan_reprocess_timer = QTimer(self)
|
||||
self._bscan_reprocess_timer.setSingleShot(True)
|
||||
self._bscan_reprocess_timer.timeout.connect(self._reprocess_history_through_processor)
|
||||
self._gpr_lookup_table = None
|
||||
self._gpr_image_item = None
|
||||
self._gpr_tx_item = None
|
||||
@@ -305,9 +377,9 @@ class AppWindow(
|
||||
self._active_processing_mode = "pass_through"
|
||||
self._radar_limits: dict[str, float | int] | None = None
|
||||
|
||||
def _history_limit_from_config(self) -> int:
|
||||
"""Return B-scan render history limit derived from configured ring capacities."""
|
||||
return self._history_limit_for_config(self._defaults_config)
|
||||
def _cpp_bscan_replay_window_from_config(self) -> int:
|
||||
"""Return the C++ B-scan replay window for the active config."""
|
||||
return self._cpp_bscan_replay_window_for_config(self._defaults_config)
|
||||
|
||||
def _save_history_limit_from_config(self) -> int:
|
||||
"""Return maxlen for GUI snapshot-save deques (independent of ring capacities)."""
|
||||
@@ -541,20 +613,8 @@ class AppWindow(
|
||||
"""Return full chained traceback for error dialogs and log details."""
|
||||
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
||||
|
||||
def _append_log_entry(
|
||||
self,
|
||||
level: str,
|
||||
text: str,
|
||||
*,
|
||||
details: str | None = None,
|
||||
once_key: str | None = None,
|
||||
) -> None:
|
||||
"""Append formatted log entry with timestamp and optional details."""
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
return
|
||||
self._logged_once_keys.add(once_key)
|
||||
|
||||
def _render_log_entry_html(self, level: str, text: str, details: str | None = None) -> str:
|
||||
"""Render one log entry as the panel's HTML block."""
|
||||
level_upper = level.upper()
|
||||
palette = {
|
||||
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
|
||||
@@ -576,16 +636,30 @@ class AppWindow(
|
||||
"<pre style='margin:3px 0 0 16px; color:"
|
||||
f"{detail_color};'>{html.escape(details)}</pre>"
|
||||
)
|
||||
return "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
||||
|
||||
def _append_log_entry(
|
||||
self,
|
||||
level: str,
|
||||
text: str,
|
||||
*,
|
||||
details: str | None = None,
|
||||
once_key: str | None = None,
|
||||
) -> None:
|
||||
"""Append formatted log entry with timestamp and optional details."""
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
return
|
||||
self._logged_once_keys.add(once_key)
|
||||
|
||||
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
||||
cursor = self._log_box.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self._log_box.setTextCursor(cursor)
|
||||
self._log_box.insertHtml(entry_html)
|
||||
self._log_box.insertHtml(self._render_log_entry_html(level, text, details))
|
||||
self._log_box.insertPlainText("\n")
|
||||
self._log_box.ensureCursorVisible()
|
||||
|
||||
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
||||
if level.upper() == "ERROR" and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
def _on_log_level_selected(self, level_text: str) -> None:
|
||||
@@ -745,6 +819,10 @@ class AppWindow(
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
# 0) Drop the B-scan replay writer so a pending debounce cannot push into a
|
||||
# ring we are about to tear down.
|
||||
self._bscan_reprocess_timer.stop()
|
||||
self._close_replay_ring_writer()
|
||||
# 1) Abort active capture first (releases exclusive hardware resources).
|
||||
self._abort_capture_sequence(resume_pipeline=False)
|
||||
# 2) Stop all managed processes/readers.
|
||||
|
||||
@@ -360,6 +360,10 @@ class AppWindowLiveProcessingMixin:
|
||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
# The processor only refreshed its own newest sweeps; queue a full
|
||||
# recompute of everything on screen. Debounced, so dragging a spin box
|
||||
# sends one burst instead of one per step.
|
||||
self._schedule_bscan_history_reprocess()
|
||||
elif self._is_gpr_processing_mode(current_mode):
|
||||
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
|
||||
collection = latest
|
||||
@@ -508,7 +512,6 @@ class AppWindowLiveProcessingMixin:
|
||||
|
||||
def _clear_history_mode_caches(self) -> None:
|
||||
"""Drop cached render state for pass-through, B-scan, and GPR views."""
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._clear_bscan_plot_history()
|
||||
if hasattr(self, "_bscan_plot"):
|
||||
self._bscan_plot.clear()
|
||||
|
||||
@@ -61,7 +61,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
|
||||
return ",".join(
|
||||
f"{int(combo.input)}:{int(combo.output)}"
|
||||
for combo in RunConfigModel.build_matrix_radar_virtual_combos()
|
||||
for combo in self._defaults_config.build_runtime_combos()
|
||||
)
|
||||
|
||||
def _sync_pass_through_y_controls(self) -> None:
|
||||
@@ -121,15 +121,15 @@ class AppWindowConfigProfileIOMixin:
|
||||
|
||||
Save-side deques use a config-independent limit so that processing-side
|
||||
ring capacities can stay small without truncating the save buffer. The
|
||||
B-scan render limit still follows ring capacities to keep plot updates
|
||||
responsive.
|
||||
C++ replay window still follows ring capacities, since it bounds how much
|
||||
of the history the processor can re-publish coherently.
|
||||
"""
|
||||
save_history_limit = self._save_history_limit_for_config(config)
|
||||
bscan_history_limit = self._history_limit_for_config(config)
|
||||
bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(config)
|
||||
self._raw_history = deque(self._raw_history, maxlen=save_history_limit)
|
||||
self._pre_history = deque(self._pre_history, maxlen=save_history_limit)
|
||||
self._result_history = deque(self._result_history, maxlen=save_history_limit)
|
||||
self._bscan_history_limit = bscan_history_limit
|
||||
self._bscan_cpp_replay_window = bscan_cpp_replay_window
|
||||
self._clear_bscan_plot_history()
|
||||
|
||||
def _save_current_config(self) -> None:
|
||||
@@ -283,6 +283,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._bscan_start_freq_mhz,
|
||||
self._bscan_stop_freq_mhz,
|
||||
self._bscan_subtract_mean_ascan,
|
||||
self._bscan_history_window,
|
||||
self._gpr_relative_permittivity,
|
||||
self._gpr_tx_geometry_input,
|
||||
self._gpr_rx_geometry_input,
|
||||
@@ -438,6 +439,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz))
|
||||
self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz))
|
||||
self._bscan_subtract_mean_ascan.setChecked(bool(gui_state.processing.bscan.subtract_mean_ascan))
|
||||
self._bscan_history_window.setValue(int(gui_state.processing.bscan.history_window_scans))
|
||||
|
||||
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
|
||||
self._gpr_tx_geometry_input.setPlainText(
|
||||
|
||||
@@ -34,6 +34,13 @@ from python_app.storage.npz_store import radar_key_from_config
|
||||
# history without touching the processing-side ring sizes.
|
||||
GUI_SAVE_HISTORY_LIMIT: int = 1000
|
||||
|
||||
# Mirror of `kBscanReplayWindow` in
|
||||
# data_acq_and_processing/processing/data_processor/src/data_processor.cpp. When a
|
||||
# live B-scan setting changes, the C++ processor re-processes and re-publishes only
|
||||
# this many of the newest collections; anything older keeps the payload it was first
|
||||
# computed with. Keep the two constants in sync.
|
||||
CPP_BSCAN_REPLAY_WINDOW: int = 50
|
||||
|
||||
|
||||
class AppWindowConfigStateBuildersMixin:
|
||||
"""Build stable and GUI-only config models from current widget state."""
|
||||
@@ -125,7 +132,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
if config.is_matrix_radar:
|
||||
return ",".join(
|
||||
f"{int(combo.input)}:{int(combo.output)}"
|
||||
for combo in RunConfigModel.build_matrix_radar_virtual_combos()
|
||||
for combo in config.build_runtime_combos()
|
||||
)
|
||||
combos = list(config.combos)
|
||||
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
@@ -163,14 +170,24 @@ class AppWindowConfigStateBuildersMixin:
|
||||
return (min(x_values) - margin_m, max(x_values) + margin_m)
|
||||
|
||||
@staticmethod
|
||||
def _history_limit_for_config(config: RunConfigModel) -> int:
|
||||
"""Return B-scan render history limit derived from config ring capacities."""
|
||||
def _cpp_bscan_replay_window_for_config(config: RunConfigModel) -> int:
|
||||
"""Return how many newest collections the C++ processor re-processes on a
|
||||
live B-scan settings change.
|
||||
|
||||
Deliberately reproduces `replay_history_limit()` in `data_processor.cpp`
|
||||
formula-for-formula. The ring capacities matter because `ShmRing` overwrites
|
||||
the oldest unread slot on overflow, so a replay burst must fit in the results
|
||||
ring for the GUI to receive all of it.
|
||||
|
||||
This is the single place to change if the replay window ever becomes
|
||||
configurable on the C++ side.
|
||||
"""
|
||||
return max(
|
||||
1,
|
||||
min(
|
||||
int(config.rings.raw_tap.capacity),
|
||||
int(config.rings.preprocessed_tap.capacity),
|
||||
int(config.rings.preprocessed.capacity),
|
||||
int(config.rings.results.capacity),
|
||||
CPP_BSCAN_REPLAY_WINDOW,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -180,7 +197,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
|
||||
Independent of ring capacities — see :data:`GUI_SAVE_HISTORY_LIMIT`.
|
||||
The `config` argument is kept for symmetry with
|
||||
:meth:`_history_limit_for_config` and possible future per-profile
|
||||
:meth:`_cpp_bscan_replay_window_for_config` and possible future per-profile
|
||||
overrides.
|
||||
"""
|
||||
del config
|
||||
@@ -344,6 +361,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||
subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||
history_window_scans=int(self._bscan_history_window.value()),
|
||||
),
|
||||
gpr=GuiGprStateModel(
|
||||
input_positions=self._gpr_input_positions_input.text().strip(),
|
||||
|
||||
@@ -150,6 +150,13 @@ class AppWindowPipelineMixin:
|
||||
# completed and the GUI hung.
|
||||
single_capture_start_ns = time.monotonic_ns() if single_capture else None
|
||||
|
||||
# `data_preprocessor` is about to become the owner of the preprocessed ring
|
||||
# again, so the GUI must stop holding a writer into it. A queued replay would
|
||||
# otherwise interleave with the real producer.
|
||||
self._bscan_reprocess_timer.stop()
|
||||
self._close_replay_ring_writer()
|
||||
self._discard_bscan_replay_results()
|
||||
|
||||
self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture)
|
||||
self._close_readers()
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
@@ -682,7 +689,7 @@ class AppWindowPipelineMixin:
|
||||
def _reset_runtime_history(self) -> None:
|
||||
"""Reset runtime history and B-scan caches."""
|
||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._discard_bscan_replay_results()
|
||||
self._clear_history_mode_caches()
|
||||
self._update_history_indicator()
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Plot-rendering mixins split by rendering mode."""
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
|
||||
from python_app.gui.controllers.app_window_plot.bscan_replay_mixin import AppWindowBscanReplayMixin
|
||||
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
||||
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
|
||||
|
||||
__all__ = [
|
||||
"AppWindowBscanPlotMixin",
|
||||
"AppWindowBscanReplayMixin",
|
||||
"AppWindowGprPlotMixin",
|
||||
"AppWindowTracePlotMixin",
|
||||
]
|
||||
|
||||
@@ -16,17 +16,19 @@ def _result_tail(
|
||||
*,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> list[ResultCollection]:
|
||||
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
|
||||
filtered = [
|
||||
collection
|
||||
for collection in result_history[-history_limit:]
|
||||
if int(collection.collection_id) > int(floor_collection_id)
|
||||
]
|
||||
"""Return the de-duplicated newest `history_limit` entries for B-scan usage.
|
||||
|
||||
Selection is purely positional. An earlier version also dropped entries below a
|
||||
`collection_id` floor, which cannot work here: ids are neither dense (the results
|
||||
ring overwrites unread slots) nor monotonic across a run boundary (the C++ side
|
||||
numbers from 1 again). Given the floor was derived from the first entry of this
|
||||
very slice, the comparison provably removed nothing when ids ascend, and removed
|
||||
exactly the newest frames when they do not.
|
||||
"""
|
||||
unique_reversed_tail: list[ResultCollection] = []
|
||||
seen_keys: set[tuple[int, int]] = set()
|
||||
for collection in reversed(filtered):
|
||||
for collection in reversed(result_history[-history_limit:]):
|
||||
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
@@ -43,15 +45,14 @@ def build_bscan_signature(
|
||||
subtract_mean_ascan_enabled: bool,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
floor_collection_id=floor_collection_id,
|
||||
)
|
||||
return (
|
||||
int(history_limit),
|
||||
str(live_config.bscan_axis),
|
||||
str(live_config.bscan_channel),
|
||||
float(live_config.bscan_cut_m),
|
||||
@@ -60,7 +61,6 @@ def build_bscan_signature(
|
||||
float(live_config.bscan_start_freq_mhz),
|
||||
float(live_config.bscan_stop_freq_mhz),
|
||||
bool(subtract_mean_ascan_enabled),
|
||||
int(floor_collection_id),
|
||||
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
||||
)
|
||||
|
||||
@@ -81,19 +81,27 @@ def apply_mean_ascan_subtraction(
|
||||
def rebuild_bscan_history_from_results(
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
stats: dict[str, object] | None = None,
|
||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||
"""Rebuild B-scan history and depth axes from processed result payloads."""
|
||||
"""Rebuild B-scan history and depth axes from processed result payloads.
|
||||
|
||||
Pass `stats` to receive a breakdown of why the rebuilt image may hold fewer
|
||||
columns than `history_limit`. The three causes are independent and only
|
||||
distinguishable here: too little history, frames carrying no `bscan` payload, and
|
||||
depth-axis changes that reset the accumulated deque (see below).
|
||||
"""
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
floor_collection_id=floor_collection_id,
|
||||
)
|
||||
axis_resets = 0
|
||||
without_bscan = 0
|
||||
|
||||
for collection in result_tail:
|
||||
carried_bscan = False
|
||||
for block in collection.blocks:
|
||||
key = (block.combo.input, block.combo.output)
|
||||
for payload in block.payloads:
|
||||
@@ -109,6 +117,7 @@ def rebuild_bscan_history_from_results(
|
||||
if depth_axis.size == 0 or amplitudes.size == 0:
|
||||
continue
|
||||
|
||||
carried_bscan = True
|
||||
history = history_by_combo.get(key)
|
||||
stored_axis = depth_axis_by_combo.get(key)
|
||||
if (
|
||||
@@ -117,11 +126,25 @@ def rebuild_bscan_history_from_results(
|
||||
or stored_axis.shape != depth_axis.shape
|
||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||
):
|
||||
# A changed depth axis makes previously accumulated columns
|
||||
# un-stackable, so the deque restarts and everything gathered so far
|
||||
# for this combo is dropped. Frames computed with different
|
||||
# bscan_max_depth_m / frequency bounds land here — which is exactly
|
||||
# what a partially replayed history looks like.
|
||||
if history is not None:
|
||||
axis_resets += 1
|
||||
history = deque(maxlen=history_limit)
|
||||
history_by_combo[key] = history
|
||||
depth_axis_by_combo[key] = depth_axis.copy()
|
||||
|
||||
history.append(amplitudes.copy())
|
||||
if not carried_bscan:
|
||||
without_bscan += 1
|
||||
|
||||
if stats is not None:
|
||||
stats["tail"] = len(result_tail)
|
||||
stats["without_bscan"] = without_bscan
|
||||
stats["axis_resets"] = axis_resets
|
||||
|
||||
return history_by_combo, depth_axis_by_combo
|
||||
|
||||
@@ -213,6 +236,7 @@ class AppWindowBscanPlotMixin:
|
||||
depth_max = float(np.max(depth_axis))
|
||||
depth_span = max(depth_max - depth_min, 1e-6)
|
||||
sweep_count = sweeps.shape[0]
|
||||
self._warn_if_bscan_window_underfilled()
|
||||
sweep_width = float(max(sweep_count, 1))
|
||||
x_min = 0.5
|
||||
x_max = x_min + sweep_width
|
||||
@@ -236,9 +260,48 @@ class AppWindowBscanPlotMixin:
|
||||
)
|
||||
return True
|
||||
|
||||
def _warn_if_bscan_window_underfilled(self) -> None:
|
||||
"""Warn once when there is less retained raw material than the operator asked for.
|
||||
|
||||
Settings edits are recomputed across the whole image by re-feeding
|
||||
`_pre_history` to the processor (see :class:`AppWindowBscanReplayMixin`), so a
|
||||
wide window is no longer a coherency problem. What it can still be is an empty
|
||||
promise: asking for more sweeps than were ever captured simply shows fewer.
|
||||
"""
|
||||
window = self._bscan_display_window_scans()
|
||||
retained = len(self._pre_history)
|
||||
if retained >= window:
|
||||
return
|
||||
|
||||
self._log_warning(
|
||||
f"B-scan is set to show {window} sweeps but only {retained} are retained; "
|
||||
"the image shows what history there is.",
|
||||
details=(
|
||||
"The GUI keeps a bounded history of preprocessed sweeps, so a window "
|
||||
"wider than the run itself cannot be filled.\n"
|
||||
f"Capture more sweeps, or set 'Scans to show (stopped)' to {retained} or less."
|
||||
),
|
||||
# Keyed on the operator-controlled window rather than the live retained count,
|
||||
# so that repeated "Remove Last" does not re-warn on every click.
|
||||
once_key=f"bscan_window_underfilled_{window}",
|
||||
)
|
||||
|
||||
def _bscan_display_window_scans(self) -> int:
|
||||
"""Return how many past sweeps the B-scan should render right now.
|
||||
|
||||
While acquisition runs the window stays at the C++ replay window: results
|
||||
arrive continuously, the whole history is rebuilt on every new one, and a
|
||||
1000-wide rebuild on the live path would cost ~20x per frame.
|
||||
|
||||
Once stopped, the operator reviews a frozen history, so the user-configured
|
||||
window applies and may reach back over the whole GUI result deque.
|
||||
"""
|
||||
if self._supervisor.is_running():
|
||||
return int(self._bscan_cpp_replay_window)
|
||||
return max(1, int(self._bscan_history_window.value()))
|
||||
|
||||
def _sync_bscan_history_from_results(self) -> None:
|
||||
"""Rebuild B-scan history cache when live params or inputs changed."""
|
||||
self._advance_bscan_floor_to_cpp_window()
|
||||
signature = self._bscan_signature()
|
||||
if signature == self._bscan_render_signature:
|
||||
return
|
||||
@@ -248,25 +311,84 @@ class AppWindowBscanPlotMixin:
|
||||
def _bscan_signature(self) -> tuple[object, ...]:
|
||||
"""Build state signature for B-scan history cache invalidation."""
|
||||
live_config = self._live_processing_config()
|
||||
result_history = list(self._result_history)
|
||||
# Must read the same source the rebuild will, or the cache decides nothing
|
||||
# changed while the image would in fact be built from different collections.
|
||||
result_history, _from_replay = self._bscan_source_collections()
|
||||
return build_bscan_signature(
|
||||
live_config=live_config,
|
||||
subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_history_limit,
|
||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||
history_limit=self._bscan_display_window_scans(),
|
||||
)
|
||||
|
||||
def _bscan_source_collections(self) -> tuple[list[ResultCollection], bool]:
|
||||
"""Return the collections the image is built from, and whether they are replayed.
|
||||
|
||||
A completed replay is the better source: it is exactly `window` long and every
|
||||
entry went through the processor with the same settings. The runtime history is
|
||||
not usable right after one, because results whose ids it never held are appended
|
||||
out of order, leaving the deque unsorted for the rest of the session.
|
||||
"""
|
||||
replayed = getattr(self, "_bscan_replay_results", None)
|
||||
if replayed:
|
||||
return list(replayed), True
|
||||
return list(self._result_history), False
|
||||
|
||||
def _rebuild_bscan_history_from_results(self) -> None:
|
||||
"""Recompute B-scan history cache from results history buffer."""
|
||||
result_history = list(self._result_history)
|
||||
result_history, from_replay = self._bscan_source_collections()
|
||||
window = self._bscan_display_window_scans()
|
||||
stats: dict[str, object] = {}
|
||||
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_history_limit,
|
||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||
history_limit=window,
|
||||
stats=stats,
|
||||
)
|
||||
self._bscan_history_by_combo = history_by_combo
|
||||
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
||||
self._log_bscan_window_shortfall(
|
||||
window=window,
|
||||
result_history_len=len(result_history),
|
||||
from_replay=from_replay,
|
||||
history_by_combo=history_by_combo,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
def _log_bscan_window_shortfall(
|
||||
self,
|
||||
*,
|
||||
window: int,
|
||||
result_history_len: int,
|
||||
from_replay: bool,
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||
stats: dict[str, object],
|
||||
) -> None:
|
||||
"""Explain at DEBUG why the image holds fewer columns than were requested.
|
||||
|
||||
Three independent causes produce the same symptom, so each is reported as its
|
||||
own number rather than a single verdict:
|
||||
* `tail` — the run simply produced fewer sweeps than were asked for;
|
||||
* `without_bscan` — frames processed in another mode carry no bscan payload;
|
||||
* `axis_resets` — a changed depth axis restarted the deque, dropping every
|
||||
column gathered before it (the usual cause after a partial replay);
|
||||
* per-combo counts — the image renders one combo at a time.
|
||||
"""
|
||||
rendered = max((len(history) for history in history_by_combo.values()), default=0)
|
||||
if rendered >= window:
|
||||
return
|
||||
|
||||
per_combo = ", ".join(
|
||||
f"in{input_pos}/out{output_pos}={len(history)}"
|
||||
for (input_pos, output_pos), history in sorted(history_by_combo.items())
|
||||
)
|
||||
self._log_debug(
|
||||
f"B-scan window not filled: rendered={rendered} of requested={window}. "
|
||||
f"source={'replay' if from_replay else 'result history'} len={result_history_len}, "
|
||||
f"newest-{window} tail={stats.get('tail')}, "
|
||||
f"of those without a bscan payload={stats.get('without_bscan')}, "
|
||||
f"depth-axis resets={stats.get('axis_resets')}. "
|
||||
f"Per combo: {per_combo or 'none'}."
|
||||
)
|
||||
|
||||
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
|
||||
"""Choose combo history key to render."""
|
||||
@@ -351,30 +473,6 @@ class AppWindowBscanPlotMixin:
|
||||
self._bscan_depth_axis_by_combo.clear()
|
||||
self._bscan_render_signature = None
|
||||
|
||||
def _advance_bscan_floor_to_cpp_window(self) -> None:
|
||||
"""Clamp B-scan source history to C++ available replay window."""
|
||||
if not self._result_history:
|
||||
return
|
||||
|
||||
cpp_window_limit = min(
|
||||
int(self._defaults_config.rings.preprocessed.capacity),
|
||||
int(self._defaults_config.rings.results.capacity),
|
||||
)
|
||||
cpp_window_limit = max(1, cpp_window_limit)
|
||||
latest_collection_id = int(self._result_history[-1].collection_id)
|
||||
current_floor = int(self._bscan_history_floor_collection_id)
|
||||
|
||||
# Collection ids restart from 1 on new C++ run; release floor only while
|
||||
# acquisition is running, so manual "remove last" behavior in stopped mode
|
||||
# remains deterministic.
|
||||
if latest_collection_id < current_floor and self._supervisor.is_running():
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
current_floor = 0
|
||||
|
||||
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
|
||||
if floor_candidate > current_floor:
|
||||
self._bscan_history_floor_collection_id = floor_candidate
|
||||
|
||||
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
||||
"""Create or return secondary right-axis ViewBox for phase curves."""
|
||||
plot_item = self._bscan_plot.getPlotItem()
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Re-feed retained sweeps to the processor so it recomputes the whole B-scan.
|
||||
|
||||
The processor keeps only ~50 preprocessed sweeps of its own (`kBscanReplayWindow` in
|
||||
`data_processor.cpp`), so changing a live B-scan setting used to refresh just the newest
|
||||
50 columns while everything older kept the parameters it was captured with — one image
|
||||
stitched from two parameter sets.
|
||||
|
||||
The GUI already holds up to 1000 preprocessed sweeps in `_pre_history`, byte-identical
|
||||
to what the processor consumes: `data_preprocessor` pushes the very same serialized
|
||||
buffer into both the working ring and the tap the GUI reads. So instead of making the
|
||||
processor hoard sweeps, we hand its own raw material back to it and let the ordinary
|
||||
`pop -> process -> publish` path recompute every column with the current settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from python_app.gui.runtime.history import record_result_history
|
||||
from python_app.orchestration.shm import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
|
||||
|
||||
# The results ring overwrites unread slots, so a burst must never exceed what the GUI
|
||||
# drains between chunks. Comfortably below the (typically 50-slot) ring capacity.
|
||||
_REPLAY_CHUNK_SIZE = 12
|
||||
|
||||
# Per-chunk budget. Generous: the processor may be busy, and overshooting only costs
|
||||
# a redraw that reflects fewer columns.
|
||||
_REPLAY_CHUNK_TIMEOUT_S = 2.0
|
||||
|
||||
# Restarting the timer on every edit collapses a spin-box drag into one replay.
|
||||
_BSCAN_REPROCESS_DEBOUNCE_MS = 300
|
||||
|
||||
|
||||
class AppWindowBscanReplayMixin:
|
||||
"""Recomputes the full visible B-scan by re-feeding sweeps to the processor."""
|
||||
|
||||
def _reprocess_history_through_processor(self) -> bool:
|
||||
"""Re-feed the visible tail of `_pre_history` so every column is recomputed.
|
||||
|
||||
Returns True when a replay actually ran. Refuses (returning False) whenever the
|
||||
preconditions for safely writing into the processor's input ring do not hold.
|
||||
"""
|
||||
# `_pump_events_during_drain` runs the event loop, so a settings edit made while
|
||||
# a long replay is in flight can re-arm the debounce and fire this method inside
|
||||
# itself — two nested bursts sharing one ring and one result count.
|
||||
if getattr(self, "_bscan_replay_active", False):
|
||||
return False
|
||||
if not self._can_reprocess_history():
|
||||
return False
|
||||
|
||||
window = self._bscan_display_window_scans()
|
||||
tail = list(self._pre_history)[-window:]
|
||||
# `_pre_history` and `_result_history` are fed by two different rings, each
|
||||
# dropping independently when the producer outruns the GUI. A low overlap means
|
||||
# the replayed results arrive under ids the result history never held, so they
|
||||
# are appended rather than replacing the columns already on screen.
|
||||
result_keys = {
|
||||
(int(c.collection_id), int(c.monotonic_ns)) for c in self._result_history
|
||||
}
|
||||
overlap = sum(
|
||||
1 for c in tail if (int(c.collection_id), int(c.monotonic_ns)) in result_keys
|
||||
)
|
||||
self._log_debug(
|
||||
f"B-scan replay starting: window={window}, preprocessed history="
|
||||
f"{len(self._pre_history)}, result history={len(self._result_history)}, "
|
||||
f"to re-send={len(tail)}, of those already in result history={overlap}."
|
||||
)
|
||||
if not tail:
|
||||
return False
|
||||
|
||||
writer = self._ensure_replay_ring_writer()
|
||||
if writer is None:
|
||||
return False
|
||||
|
||||
# The processor replays its own retained sweeps on every live-config revision
|
||||
# bump. We are about to send the same collections (and more), so suppress it
|
||||
# rather than let it publish the newest 50 twice.
|
||||
self._write_live_processing_config(reprocess_current_result=False)
|
||||
|
||||
# Our own event pumping would otherwise let the poll timer fire and consume the
|
||||
# replayed results through `_read_all_results`, which records them to disk and
|
||||
# feeds the pipeline metrics. Restart in `finally`: losing the ring poll on an
|
||||
# exception would leave the GUI permanently blind.
|
||||
self._timer.stop()
|
||||
self._bscan_replay_active = True
|
||||
sent = 0
|
||||
received = 0
|
||||
replayed: list = []
|
||||
try:
|
||||
for start in range(0, len(tail), _REPLAY_CHUNK_SIZE):
|
||||
chunk = tail[start : start + _REPLAY_CHUNK_SIZE]
|
||||
pushed = 0
|
||||
for collection in chunk:
|
||||
payload = serialize_trace_collection(collection, PREPROC_MAGIC)
|
||||
if not writer.push(payload):
|
||||
# Only fails when the payload exceeds the slot size, which is a
|
||||
# config problem rather than a transient one: stop the burst.
|
||||
self._log_warning(
|
||||
"B-scan replay stopped: a preprocessed sweep does not fit the ring slot.",
|
||||
details=(
|
||||
f"payload={len(payload)} bytes, "
|
||||
f"slot={writer.slot_size_bytes} bytes"
|
||||
),
|
||||
once_key="bscan_replay_payload_too_large",
|
||||
)
|
||||
break
|
||||
pushed += 1
|
||||
sent += pushed
|
||||
got = self._collect_replayed_results(
|
||||
expected=pushed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S, into=replayed
|
||||
)
|
||||
received += got
|
||||
if got < pushed:
|
||||
# A short chunk means the processor did not answer in time; keep
|
||||
# going, but say which one so a systematic stall is visible.
|
||||
self._log_debug(
|
||||
f"B-scan replay chunk at offset {start}: pushed={pushed}, recovered={got}."
|
||||
)
|
||||
if pushed != len(chunk):
|
||||
break
|
||||
finally:
|
||||
self._bscan_replay_active = False
|
||||
self._timer.start()
|
||||
|
||||
# Render straight from what came back rather than from `_result_history`.
|
||||
#
|
||||
# The two histories are fed by different rings that drop independently, so the
|
||||
# sweeps we re-sent only partly overlap the results already on record. The
|
||||
# non-overlapping ones get appended to the deque even though their ids are old,
|
||||
# so its newest `window` entries are a mix of freshly and stale-processed
|
||||
# frames. What came back is by construction the right count and uniformly
|
||||
# processed, so use it directly.
|
||||
self._bscan_replay_results = replayed
|
||||
|
||||
# Replayed collections keep their original ids and timestamps, so the render
|
||||
# signature is unchanged even though the payload values are not. Drop it or the
|
||||
# cache would decide nothing needs rebuilding.
|
||||
self._bscan_render_signature = None
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
|
||||
if received < sent:
|
||||
self._log_warning(
|
||||
f"B-scan replay recovered {received} of {sent} re-sent sweeps; "
|
||||
"some columns may still show their captured settings.",
|
||||
once_key=f"bscan_replay_incomplete_{sent}_{received}",
|
||||
)
|
||||
self._log_debug(f"B-scan replay finished: sent={sent}, recovered={received}.")
|
||||
return True
|
||||
|
||||
def _can_reprocess_history(self) -> bool:
|
||||
"""Return whether re-feeding the processor's input ring is safe right now."""
|
||||
if self._processing_mode.currentText() != "bscan":
|
||||
return False
|
||||
# `data_preprocessor` owns the preprocessed ring while acquisition runs; writing
|
||||
# into it concurrently would corrupt the sequence counters.
|
||||
if self._supervisor.is_running():
|
||||
return False
|
||||
if not self._supervisor.is_processor_running():
|
||||
return False
|
||||
return self._result_reader is not None
|
||||
|
||||
def _collect_replayed_results(
|
||||
self, *, expected: int, timeout_s: float, into: list | None = None
|
||||
) -> int:
|
||||
"""Pop `expected` replayed results, recording them into runtime history.
|
||||
|
||||
Deliberately bypasses `_read_all_results`: that path also feeds the pipeline
|
||||
metrics and the disk recorder, and a replay is neither new acquisition nor
|
||||
something that should be written to disk a second time.
|
||||
|
||||
`into` also receives them in arrival order, which is what the B-scan renders —
|
||||
see `_reprocess_history_through_processor` for why the runtime history alone is
|
||||
not a usable source afterwards.
|
||||
"""
|
||||
if expected <= 0 or self._result_reader is None:
|
||||
return 0
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
received = 0
|
||||
while received < expected and time.monotonic() < deadline:
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
self._pump_events_during_drain(0.005)
|
||||
continue
|
||||
record_result_history(self._result_history, collection)
|
||||
if into is not None:
|
||||
into.append(collection)
|
||||
received += 1
|
||||
return received
|
||||
|
||||
def _ensure_replay_ring_writer(self) -> ShmRingWriter | None:
|
||||
"""Return a writer attached to the processor's input ring, creating it lazily.
|
||||
|
||||
Geometry comes from `_active_run_config` — the config the C++ side actually
|
||||
started with — never from the editable `_defaults_config`: `ShmRingWriter` owns
|
||||
the rings it opens and *recreates* a segment whose geometry disagrees, which
|
||||
would destroy the ring under a live processor.
|
||||
"""
|
||||
existing = getattr(self, "_replay_ring_writer", None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
config = getattr(self, "_active_run_config", None)
|
||||
if config is None:
|
||||
# Processor outlived the GUI that started it: its ring geometry is unknown,
|
||||
# and guessing risks recreating the segment underneath it.
|
||||
self._log_warning(
|
||||
"Cannot recompute the full B-scan: this GUI session did not start the "
|
||||
"pipeline, so the processor's ring geometry is unknown.",
|
||||
once_key="bscan_replay_no_active_run_config",
|
||||
)
|
||||
return None
|
||||
|
||||
ring = config.rings.preprocessed
|
||||
try:
|
||||
self._replay_ring_writer = ShmRingWriter(
|
||||
ring.name, int(ring.capacity), int(ring.slot_size_bytes)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to open the B-scan replay ring writer", exc, level="WARN")
|
||||
self._replay_ring_writer = None
|
||||
return self._replay_ring_writer
|
||||
|
||||
def _discard_bscan_replay_results(self) -> None:
|
||||
"""Fall back to the runtime history as the render source.
|
||||
|
||||
Called whenever the replayed set stops describing what should be on screen:
|
||||
acquisition resuming (live frames must win) or the history being edited.
|
||||
"""
|
||||
if not getattr(self, "_bscan_replay_results", None):
|
||||
return
|
||||
self._bscan_replay_results = []
|
||||
self._bscan_render_signature = None
|
||||
|
||||
def _close_replay_ring_writer(self) -> None:
|
||||
"""Detach from the processor's input ring (safe to call repeatedly)."""
|
||||
writer = getattr(self, "_replay_ring_writer", None)
|
||||
if writer is None:
|
||||
return
|
||||
try:
|
||||
writer.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to close the B-scan replay ring writer", exc, level="WARN")
|
||||
finally:
|
||||
self._replay_ring_writer = None
|
||||
|
||||
def _schedule_bscan_history_reprocess(self) -> None:
|
||||
"""Debounce a full recompute so dragging a spin box does not send hundreds of sweeps."""
|
||||
if not self._can_reprocess_history():
|
||||
return
|
||||
self._bscan_reprocess_timer.start(_BSCAN_REPROCESS_DEBOUNCE_MS)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from python_app.gui.controllers.app_window_plot import (
|
||||
AppWindowBscanPlotMixin,
|
||||
AppWindowBscanReplayMixin,
|
||||
AppWindowGprPlotMixin,
|
||||
AppWindowTracePlotMixin,
|
||||
)
|
||||
@@ -13,6 +14,7 @@ from python_app.models.dataset_model import ResultCollection
|
||||
class AppWindowPlotMixin(
|
||||
AppWindowTracePlotMixin,
|
||||
AppWindowBscanPlotMixin,
|
||||
AppWindowBscanReplayMixin,
|
||||
AppWindowGprPlotMixin,
|
||||
):
|
||||
"""Routes plotting to trace, B-scan, or GPR-specific mixins."""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.gui.trace_png_export import export_trace_png
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
@@ -10,7 +12,10 @@ from python_app.orchestration.preprocess_assets import (
|
||||
preprocess_asset_channel,
|
||||
preprocess_asset_display_name,
|
||||
)
|
||||
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
|
||||
from python_app.workflows.kamil_adc_neutral_preprocess import (
|
||||
build_neutral_s21_sets,
|
||||
supports_neutral_preprocess_sets,
|
||||
)
|
||||
from python_app.workflows.multi_radar_capture_workflow import (
|
||||
MultiRadarCaptureBatch,
|
||||
MultiRadarSequentialCaptureSession,
|
||||
@@ -216,8 +221,8 @@ class AppWindowPreprocessMixin:
|
||||
dialog.undo_last_requested.connect(self._undo_last_capture)
|
||||
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
|
||||
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
|
||||
dialog.create_kamil_adc_neutral_sets_requested.connect(self._create_kamil_adc_neutral_sets)
|
||||
dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
|
||||
dialog.create_neutral_sets_requested.connect(self._create_neutral_sets)
|
||||
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
|
||||
dialog.set_radar_config_summary(
|
||||
directory_path=self._preprocess_radar_scan_summary.directory_path,
|
||||
json_file_count=self._preprocess_radar_scan_summary.json_file_count,
|
||||
@@ -292,7 +297,7 @@ class AppWindowPreprocessMixin:
|
||||
f"{preprocess_asset_display_name(key)}={len(names)}"
|
||||
for key, names in available_sets.items()
|
||||
)
|
||||
dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
|
||||
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
|
||||
self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
|
||||
if unavailable_selections:
|
||||
self._log_warning(
|
||||
@@ -391,8 +396,8 @@ class AppWindowPreprocessMixin:
|
||||
self._show_exception(f"Failed to start {kind} sequence", exc)
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _create_kamil_adc_neutral_sets(self) -> None:
|
||||
"""Save neutral S21 calibration/reference sets for the current Kamil ADC settings."""
|
||||
def _create_neutral_sets(self) -> None:
|
||||
"""Save neutral S21 calibration/reference sets for the current radar settings."""
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot create neutral sets during active capture sequence",
|
||||
@@ -409,8 +414,10 @@ class AppWindowPreprocessMixin:
|
||||
pipeline_was_paused = False
|
||||
try:
|
||||
config = self._build_config()
|
||||
if not config.is_kamil_adc:
|
||||
self._show_error("Neutral S21 sets are available only for kamil_adc")
|
||||
if not supports_neutral_preprocess_sets(config):
|
||||
self._show_error(
|
||||
"Neutral S21 sets are available only for kamil_adc and librevna_multi"
|
||||
)
|
||||
return
|
||||
|
||||
radar_key = self._radar_key(config)
|
||||
@@ -425,12 +432,12 @@ class AppWindowPreprocessMixin:
|
||||
)
|
||||
|
||||
if self._supervisor.is_running():
|
||||
self._log("Pipeline paused for Kamil ADC neutral-set creation")
|
||||
self._log("Pipeline paused for neutral-set creation")
|
||||
self._stop_run()
|
||||
pipeline_was_paused = True
|
||||
|
||||
calibration, reference = build_kamil_adc_neutral_s21_sets(config)
|
||||
point_count = config.radar.kamil_adc.band.points
|
||||
calibration, reference = build_neutral_s21_sets(config)
|
||||
point_count = int(calibration.traces[0].frequency_hz.size)
|
||||
self._store.save_set("s21_calibration", radar_key, set_name, calibration)
|
||||
self._store.save_set("s21_reference", radar_key, set_name, reference)
|
||||
|
||||
@@ -445,11 +452,11 @@ class AppWindowPreprocessMixin:
|
||||
f"Neutral S21 sets saved: {set_name} ({len(calibration.traces)} combos, {point_count} points)"
|
||||
)
|
||||
self._log(
|
||||
"Kamil ADC neutral S21 sets saved: "
|
||||
"Neutral S21 sets saved: "
|
||||
f"set={set_name}, radar_key={radar_key}, combos={len(calibration.traces)}, points={point_count}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to create Kamil ADC neutral sets", exc)
|
||||
self._show_exception("Failed to create neutral S21 sets", exc)
|
||||
finally:
|
||||
if pipeline_was_paused:
|
||||
self._start_run()
|
||||
@@ -514,13 +521,23 @@ class AppWindowPreprocessMixin:
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
# The capture blocks the event loop, so clicks made during it are delivered
|
||||
# only after it finishes. `_begin_preprocess_capture` disables the action
|
||||
# buttons for that whole window (re-enabled via a posted event), so a queued
|
||||
# click lands on a disabled button instead of silently starting — and
|
||||
# advancing the combo cursor of — another capture.
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
try:
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _capture_all_remaining(self) -> None:
|
||||
"""Capture all remaining combos for the active preprocess session."""
|
||||
@@ -534,6 +551,8 @@ class AppWindowPreprocessMixin:
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
@@ -542,6 +561,7 @@ class AppWindowPreprocessMixin:
|
||||
f"{display_name} batch capture started: remaining="
|
||||
f"{session.state().total_count - session.state().captured_count}"
|
||||
)
|
||||
try:
|
||||
while not session.is_complete():
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
@@ -549,6 +569,32 @@ class AppWindowPreprocessMixin:
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _begin_preprocess_capture(self) -> bool:
|
||||
"""Mark a blocking combo capture as running; refuse when one already is.
|
||||
|
||||
Returns False for a duplicate request (e.g. a click delivered while an
|
||||
error dialog inside a capture pumps the event loop).
|
||||
"""
|
||||
if self._preprocess_capture_busy:
|
||||
self._log("Preprocess combo capture already in progress; ignoring duplicate request.")
|
||||
return False
|
||||
self._preprocess_capture_busy = True
|
||||
# Disable the sequence action buttons for the whole blocked window.
|
||||
self._update_capture_dialog_state()
|
||||
return True
|
||||
|
||||
def _end_preprocess_capture(self) -> None:
|
||||
"""Re-enable capture actions after the pending input backlog is discarded.
|
||||
|
||||
The zero-delay timer fires only after Qt has dispatched the window-system
|
||||
events queued while the capture blocked the loop; those clicks hit the
|
||||
still-disabled buttons and are dropped, then the buttons come back.
|
||||
"""
|
||||
self._preprocess_capture_busy = False
|
||||
QTimer.singleShot(0, self._update_capture_dialog_state)
|
||||
|
||||
def _on_capture_combo_failed(
|
||||
self,
|
||||
@@ -812,6 +858,10 @@ class AppWindowPreprocessMixin:
|
||||
and state.current_combo is not None
|
||||
),
|
||||
variant_count=state.variant_count,
|
||||
# While a blocking capture is executing, every action stays disabled no
|
||||
# matter what the session state allows: clicks queued during the freeze
|
||||
# must land on disabled buttons (see `_end_preprocess_capture`).
|
||||
actions_enabled=not self._preprocess_capture_busy,
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
|
||||
@@ -241,7 +241,7 @@ class AppWindowSnapshotMixin:
|
||||
retained_pre=retained_pre,
|
||||
retained_result=retained_result,
|
||||
)
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._discard_bscan_replay_results()
|
||||
self._clear_history_mode_caches()
|
||||
|
||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||
|
||||
@@ -18,6 +18,7 @@ from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
|
||||
from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget
|
||||
|
||||
|
||||
@@ -160,6 +161,15 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._bscan_subtract_mean_ascan = QCheckBox("Subtract mean A-scan")
|
||||
owner._bscan_subtract_mean_ascan.setChecked(bool(bscan_defaults.subtract_mean_ascan))
|
||||
|
||||
owner._bscan_history_window = QSpinBox()
|
||||
owner._bscan_history_window.setMinimum(1)
|
||||
owner._bscan_history_window.setMaximum(GUI_SAVE_HISTORY_LIMIT)
|
||||
owner._bscan_history_window.setValue(int(bscan_defaults.history_window_scans))
|
||||
owner._bscan_history_window.setToolTip(
|
||||
"How many past sweeps the B-scan shows once acquisition is stopped. While "
|
||||
"running, the window stays clamped to the C++ ring capacity."
|
||||
)
|
||||
|
||||
bscan_page = _build_processing_mode_page(
|
||||
owner._processing_mode_pages,
|
||||
[
|
||||
@@ -169,6 +179,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
("Gain", owner._bscan_gain),
|
||||
("Start MHz", owner._bscan_start_freq_mhz),
|
||||
("Stop MHz", owner._bscan_stop_freq_mhz),
|
||||
("Scans to show (stopped)", owner._bscan_history_window),
|
||||
owner._bscan_subtract_mean_ascan,
|
||||
],
|
||||
split_index=4,
|
||||
@@ -579,6 +590,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_subtract_mean_ascan.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_history_window.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
|
||||
@@ -46,7 +46,7 @@ class PreprocessDialog(QDialog):
|
||||
undo_last_requested = pyqtSignal()
|
||||
finalize_sequence_requested = pyqtSignal()
|
||||
abort_sequence_requested = pyqtSignal()
|
||||
create_kamil_adc_neutral_sets_requested = pyqtSignal()
|
||||
create_neutral_sets_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
"""Initialize window metadata and compose dialog UI."""
|
||||
@@ -92,17 +92,18 @@ class PreprocessDialog(QDialog):
|
||||
self._set_name_input = QLineEdit("set_001", group)
|
||||
refresh_button = QPushButton("Refresh Sets", group)
|
||||
refresh_button.clicked.connect(self.refresh_requested.emit)
|
||||
self._kamil_adc_neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
|
||||
self._kamil_adc_neutral_sets_button.setToolTip(
|
||||
"Save S21 calibration=1 and S21 reference=0 for the current Kamil ADC settings."
|
||||
self._neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
|
||||
self._neutral_sets_button.setToolTip(
|
||||
"Save S21 calibration=1 and S21 reference=0 for the current radar settings, "
|
||||
"so the pipeline can run before any real calibration exists."
|
||||
)
|
||||
self._kamil_adc_neutral_sets_button.clicked.connect(
|
||||
self.create_kamil_adc_neutral_sets_requested.emit
|
||||
self._neutral_sets_button.clicked.connect(
|
||||
self.create_neutral_sets_requested.emit
|
||||
)
|
||||
self._kamil_adc_neutral_sets_button.setVisible(False)
|
||||
self._neutral_sets_button.setVisible(False)
|
||||
header_row.addWidget(QLabel("Set name"))
|
||||
header_row.addWidget(self._set_name_input, stretch=1)
|
||||
header_row.addWidget(self._kamil_adc_neutral_sets_button)
|
||||
header_row.addWidget(self._neutral_sets_button)
|
||||
header_row.addWidget(refresh_button)
|
||||
layout.addLayout(header_row)
|
||||
layout.addLayout(self._build_median_sweep_row(group))
|
||||
@@ -353,8 +354,14 @@ class PreprocessDialog(QDialog):
|
||||
can_finalize: bool,
|
||||
can_capture_all: bool,
|
||||
variant_count: int = 1,
|
||||
actions_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Update sequence progress/status widgets."""
|
||||
"""Update sequence progress/status widgets.
|
||||
|
||||
With ``actions_enabled=False`` the progress labels still update but every
|
||||
sequence action button is kept disabled — used while a blocking capture
|
||||
runs, so input queued during the freeze cannot trigger another action.
|
||||
"""
|
||||
if kind is None:
|
||||
self._active_kind_label.setText("<none>")
|
||||
self._progress_label.setText("0 / 0")
|
||||
@@ -367,13 +374,14 @@ class PreprocessDialog(QDialog):
|
||||
self._capture_all_button.setText("Capture All Remaining")
|
||||
return
|
||||
|
||||
actions_enabled = bool(actions_enabled)
|
||||
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
|
||||
self._active_kind_label.setText(active_label)
|
||||
self._progress_label.setText(f"{captured_count} / {total_count}")
|
||||
self._undo_last_button.setEnabled(bool(can_undo))
|
||||
self._save_sequence_button.setEnabled(bool(can_finalize))
|
||||
self._capture_all_button.setEnabled(bool(can_capture_all))
|
||||
self._abort_button.setEnabled(True)
|
||||
self._undo_last_button.setEnabled(bool(can_undo) and actions_enabled)
|
||||
self._save_sequence_button.setEnabled(bool(can_finalize) and actions_enabled)
|
||||
self._capture_all_button.setEnabled(bool(can_capture_all) and actions_enabled)
|
||||
self._abort_button.setEnabled(actions_enabled)
|
||||
self._capture_all_button.setText("Capture All Remaining")
|
||||
if next_input is None or next_output is None:
|
||||
self._combo_label.setText("<complete>")
|
||||
@@ -383,7 +391,7 @@ class PreprocessDialog(QDialog):
|
||||
if int(variant_count) > 1:
|
||||
combo_text += f" | radar configs={int(variant_count)}"
|
||||
self._combo_label.setText(combo_text)
|
||||
self._capture_next_button.setEnabled(True)
|
||||
self._capture_next_button.setEnabled(actions_enabled)
|
||||
|
||||
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
|
||||
"""Replace combo-box choices for all preprocess assets."""
|
||||
@@ -413,10 +421,10 @@ class PreprocessDialog(QDialog):
|
||||
"""Set short human-readable status line."""
|
||||
self._status_label.setText(message)
|
||||
|
||||
def set_kamil_adc_neutral_sets_visible(self, visible: bool) -> None:
|
||||
"""Show Kamil ADC neutral-set shortcut only in the matching radar mode."""
|
||||
self._kamil_adc_neutral_sets_button.setVisible(bool(visible))
|
||||
self._kamil_adc_neutral_sets_button.setEnabled(bool(visible))
|
||||
def set_neutral_sets_visible(self, visible: bool) -> None:
|
||||
"""Show the neutral-set shortcut only for radar models that support it."""
|
||||
self._neutral_sets_button.setVisible(bool(visible))
|
||||
self._neutral_sets_button.setEnabled(bool(visible))
|
||||
|
||||
def reset_preview(self) -> None:
|
||||
"""Clear preview surfaces and restore default empty-state text when possible."""
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from ..exceptions import DeviceDisconnectedError, TimeoutError
|
||||
@@ -44,6 +45,10 @@ class USBTransport:
|
||||
self._rx_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._tx_lock = threading.Lock()
|
||||
# Aggregation window for the RX debug trace (see `_rx_loop`).
|
||||
self._rx_debug_bytes = 0
|
||||
self._rx_debug_chunks = 0
|
||||
self._rx_debug_window_start = 0.0
|
||||
|
||||
self.connected_serial: str | None = None
|
||||
|
||||
@@ -270,7 +275,27 @@ class USBTransport:
|
||||
|
||||
if data:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("USB RX %d bytes", len(data))
|
||||
# Aggregate: the free-running datapoint stream completes bulk
|
||||
# reads hundreds of times per second, and a log record per chunk
|
||||
# floods every handler (file, stderr, and the GUI panel, which
|
||||
# marshals each record onto the GUI thread). One summary per
|
||||
# second keeps the throughput trace without the flood.
|
||||
self._rx_debug_bytes += len(data)
|
||||
self._rx_debug_chunks += 1
|
||||
now = time.monotonic()
|
||||
if self._rx_debug_window_start == 0.0:
|
||||
self._rx_debug_window_start = now
|
||||
elif now - self._rx_debug_window_start >= 1.0:
|
||||
logger.debug(
|
||||
"USB RX %d bytes in %d chunks over %.2f s (serial=%s)",
|
||||
self._rx_debug_bytes,
|
||||
self._rx_debug_chunks,
|
||||
now - self._rx_debug_window_start,
|
||||
self.connected_serial,
|
||||
)
|
||||
self._rx_debug_bytes = 0
|
||||
self._rx_debug_chunks = 0
|
||||
self._rx_debug_window_start = now
|
||||
self._on_data(bytes(data))
|
||||
logger.debug("USB RX thread stopped")
|
||||
|
||||
|
||||
@@ -131,7 +131,14 @@ class MultiDeviceVnaController:
|
||||
if not self._reference_configuration_applied:
|
||||
self._configure_reference_clocks()
|
||||
|
||||
self._drain_all_received_packets()
|
||||
drain_started_seconds = time.monotonic()
|
||||
drained_packet_count = self._drain_all_received_packets()
|
||||
logger.debug(
|
||||
"timing: drain discarded %d stale packet(s) in %.2f ms (t=%.1f ms)",
|
||||
drained_packet_count,
|
||||
(time.monotonic() - drain_started_seconds) * 1e3,
|
||||
time.monotonic() * 1e3,
|
||||
)
|
||||
|
||||
if (
|
||||
self._sweep_is_running
|
||||
@@ -334,12 +341,16 @@ class MultiDeviceVnaController:
|
||||
self._sweep_is_running = True
|
||||
logger.debug("Sweep settings applied to all devices; sweep running")
|
||||
|
||||
def _drain_all_received_packets(self) -> None:
|
||||
def _drain_all_received_packets(self) -> int:
|
||||
"""Empty every device's received-packet queue, in parallel for 2+ devices.
|
||||
|
||||
Concurrent draining keeps cross-device timing skew small so a hardware
|
||||
cycle wrap cannot slip between per-device drains and desynchronize the
|
||||
cycle counters.
|
||||
|
||||
Returns the total number of discarded packets, which the caller logs: a large
|
||||
count means the host was far behind the free-running stream, a near-zero count
|
||||
means the drain landed right after a sweep boundary.
|
||||
"""
|
||||
# Drain every device queue in parallel rather than one after another:
|
||||
# serial drain leaves up to a few hundred microseconds of skew between
|
||||
@@ -349,22 +360,31 @@ class MultiDeviceVnaController:
|
||||
# so concurrent get_nowait calls do not contend. A single device case
|
||||
# just runs inline to avoid the thread-spawn overhead.
|
||||
if len(self._all_devices) < 2:
|
||||
for device_connection in self._all_devices:
|
||||
device_connection.drain_received_packets()
|
||||
return
|
||||
return sum(
|
||||
len(device_connection.drain_received_packets())
|
||||
for device_connection in self._all_devices
|
||||
)
|
||||
|
||||
drained_counts = [0] * len(self._all_devices)
|
||||
|
||||
def drain_one_device(device_index: int, device_connection: LibreVnaUsbBulkConnection) -> None:
|
||||
"""Drain one device's queue and record how many packets it held."""
|
||||
drained_counts[device_index] = len(device_connection.drain_received_packets())
|
||||
|
||||
drain_threads = [
|
||||
threading.Thread(
|
||||
target=device_connection.drain_received_packets,
|
||||
target=drain_one_device,
|
||||
args=(device_index, device_connection),
|
||||
name=f"drain-{device_connection.serial_number}",
|
||||
daemon=True,
|
||||
)
|
||||
for device_connection in self._all_devices
|
||||
for device_index, device_connection in enumerate(self._all_devices)
|
||||
]
|
||||
for drain_thread in drain_threads:
|
||||
drain_thread.start()
|
||||
for drain_thread in drain_threads:
|
||||
drain_thread.join()
|
||||
return sum(drained_counts)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
|
||||
|
||||
@@ -441,6 +441,7 @@ def collect_complete_running_sweep_cycles(
|
||||
def build_cycle_tracking_handler(
|
||||
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
||||
device_state: _DeviceCollectionState,
|
||||
device_label: str = "device",
|
||||
) -> Callable[[ParsedVnaDatapoint], bool]:
|
||||
"""Wrap a cycle-aware handler with cross-device cycle tracking.
|
||||
|
||||
@@ -462,6 +463,11 @@ def collect_complete_running_sweep_cycles(
|
||||
cycle_tracking_state = {
|
||||
"current_cycle_index": 0,
|
||||
"synchronized": False,
|
||||
# How many mid-sweep points were thrown away before the anchor was found.
|
||||
# Near zero means the drain landed on a sweep boundary — the case where a
|
||||
# stale point 0 could still have been in flight; a large count means the
|
||||
# remainder of the in-progress sweep was safely skipped.
|
||||
"pre_anchor_skipped": 0,
|
||||
}
|
||||
|
||||
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
|
||||
@@ -475,6 +481,7 @@ def collect_complete_running_sweep_cycles(
|
||||
|
||||
if not cycle_tracking_state["synchronized"]:
|
||||
if current_point_index != 0:
|
||||
cycle_tracking_state["pre_anchor_skipped"] += 1
|
||||
return False
|
||||
# Candidate cycle 0. Commit it only once every device confirms it
|
||||
# observed point 0 of the SAME physical sweep; otherwise reject the
|
||||
@@ -484,6 +491,14 @@ def collect_complete_running_sweep_cycles(
|
||||
report_cycle_misalignment()
|
||||
return False
|
||||
cycle_tracking_state["synchronized"] = True
|
||||
logger.debug(
|
||||
"timing: %s anchored cycle 0 after skipping %d mid-sweep point(s) of %d "
|
||||
"(t=%.1f ms)",
|
||||
device_label,
|
||||
cycle_tracking_state["pre_anchor_skipped"],
|
||||
point_count,
|
||||
time.monotonic() * 1e3,
|
||||
)
|
||||
cycle_aware_handler(parsed_datapoint, 0)
|
||||
return True
|
||||
|
||||
@@ -493,6 +508,12 @@ def collect_complete_running_sweep_cycles(
|
||||
# spurious wrap and desynchronize the cycle counter.
|
||||
if current_point_index == 0:
|
||||
cycle_tracking_state["current_cycle_index"] += 1
|
||||
logger.debug(
|
||||
"timing: %s first point of NEXT sweep arrived (cycle -> %d, t=%.1f ms)",
|
||||
device_label,
|
||||
cycle_tracking_state["current_cycle_index"],
|
||||
time.monotonic() * 1e3,
|
||||
)
|
||||
current_cycle_index = cycle_tracking_state["current_cycle_index"]
|
||||
if current_cycle_index >= cycle_count:
|
||||
# The sweep just wrapped past the final requested cycle, closing its
|
||||
@@ -505,6 +526,14 @@ def collect_complete_running_sweep_cycles(
|
||||
return False
|
||||
|
||||
cycle_aware_handler(parsed_datapoint, current_cycle_index)
|
||||
if current_point_index == point_count - 1:
|
||||
logger.debug(
|
||||
"timing: %s last point of cycle %d arrived (index=%d, t=%.1f ms)",
|
||||
device_label,
|
||||
current_cycle_index,
|
||||
current_point_index,
|
||||
time.monotonic() * 1e3,
|
||||
)
|
||||
return True
|
||||
|
||||
return handle_datapoint
|
||||
@@ -587,7 +616,9 @@ def collect_complete_running_sweep_cycles(
|
||||
point_index,
|
||||
] = port_receiver_value
|
||||
|
||||
return build_cycle_tracking_handler(handle_slave_datapoint, device_state)
|
||||
return build_cycle_tracking_handler(
|
||||
handle_slave_datapoint, device_state, device_label=f"slave{slave_index}"
|
||||
)
|
||||
|
||||
master_device_state = _DeviceCollectionState()
|
||||
collection_threads = [
|
||||
@@ -595,7 +626,9 @@ def collect_complete_running_sweep_cycles(
|
||||
target=collect_datapoints_from_device,
|
||||
args=(
|
||||
master_device_connection,
|
||||
build_cycle_tracking_handler(handle_master_datapoint, master_device_state),
|
||||
build_cycle_tracking_handler(
|
||||
handle_master_datapoint, master_device_state, device_label="master"
|
||||
),
|
||||
master_device_state,
|
||||
),
|
||||
daemon=True,
|
||||
|
||||
@@ -15,6 +15,17 @@ from python_app.hardware_full.librevna_multi_device_driver.protocol import Packe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The sweep free-runs by design, so devices stream datapoints continuously even
|
||||
# while no acquisition is consuming them (e.g. an operator pausing between manual
|
||||
# combo captures). An unbounded queue then grows without limit — hundreds of MB
|
||||
# over a few minutes — and the next acquisition's drain spends seconds discarding
|
||||
# the backlog on the GUI thread. Bound the queue and drop the OLDEST packet on
|
||||
# overflow: every acquisition drains stale packets before collecting anyway, and
|
||||
# whenever packets actually matter (ACK waits, cycle collection) a consumer is
|
||||
# already pulling, so the queue never approaches the bound. Sized to hold many
|
||||
# full sweeps of datapoints with a wide margin.
|
||||
_RECEIVED_PACKET_QUEUE_MAX = 32768
|
||||
|
||||
|
||||
class LibreVnaUsbBulkConnection:
|
||||
"""Minimal packet transport for one LibreVNA device."""
|
||||
@@ -29,7 +40,9 @@ class LibreVnaUsbBulkConnection:
|
||||
raise ValueError("serial_number is required for multi-device acquisition")
|
||||
self.serial_number = serial_number
|
||||
self._scanner = FrameScanner()
|
||||
self._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue()
|
||||
self._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue(
|
||||
maxsize=_RECEIVED_PACKET_QUEUE_MAX
|
||||
)
|
||||
self._fatal_error: Exception | None = None
|
||||
self._fatal_lock = threading.Lock()
|
||||
self._transport = USBTransport(
|
||||
@@ -108,7 +121,20 @@ class LibreVnaUsbBulkConnection:
|
||||
logger.warning("Dropping unparseable USB chunk from %s: %s", self.serial_number, exc)
|
||||
return
|
||||
for packet in packets:
|
||||
self._received_packets.put((int(packet.type), bytes(packet.payload)))
|
||||
entry = (int(packet.type), bytes(packet.payload))
|
||||
while True:
|
||||
try:
|
||||
self._received_packets.put_nowait(entry)
|
||||
break
|
||||
except queue.Full:
|
||||
# Blocking here would stall the USB read thread; discard the
|
||||
# oldest packet instead — stale data is what the pre-collect
|
||||
# drain throws away anyway. Racing a concurrent consumer just
|
||||
# means the queue already has room again.
|
||||
try:
|
||||
self._received_packets.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def _on_disconnect(self, exc: Exception) -> None:
|
||||
"""Record an asynchronous transport disconnect as the fatal error."""
|
||||
|
||||
@@ -46,13 +46,31 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
|
||||
if model == RunConfigModel.LIBREVNA_MULTI_MODEL:
|
||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||
|
||||
return MultiDeviceLibreVnaService(
|
||||
inner = MultiDeviceLibreVnaService(
|
||||
master_serial=config.radar.serial,
|
||||
slave_serials=list(config.radar.multi_device.slave_serials),
|
||||
force_external_reference=config.radar.multi_device.force_external_reference,
|
||||
recovery_attempts=config.radar.multi_device.recovery_attempts,
|
||||
backend_mode=config.radar.driver_mode,
|
||||
)
|
||||
out_physical = config.matrix_output_switch_positions
|
||||
in_physical = config.matrix_input_switch_positions
|
||||
if out_physical <= 1 and in_physical <= 1:
|
||||
return inner
|
||||
|
||||
from python_app.hardware_full.switched_matrix_radar_service import (
|
||||
SwitchedMatrixRadarService,
|
||||
build_physical_switch,
|
||||
)
|
||||
|
||||
return SwitchedMatrixRadarService(
|
||||
inner=inner,
|
||||
output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode),
|
||||
input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode),
|
||||
inner_output_positions=RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS,
|
||||
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
|
||||
settling_ms=config.runtime.settling_ms,
|
||||
)
|
||||
|
||||
if model == RunConfigModel.SN9000_MODEL:
|
||||
if config.radar.driver_mode != "native":
|
||||
|
||||
@@ -332,10 +332,15 @@ class MultiDeviceLibreVnaService:
|
||||
assert self._sweep_configuration is not None
|
||||
|
||||
self._controller.configure_continuous_sweep(self._sweep_configuration)
|
||||
# Bound the sweep itself rather than reusing `capture_start_ns`: the latter
|
||||
# is taken before any retry/recovery, so it would overstate how long the
|
||||
# traces below took to measure.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
result = self._controller.collect_running_sweep_cycles(
|
||||
1,
|
||||
datapoint_timeout_seconds=LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS,
|
||||
)
|
||||
sweep_end_ns = time.monotonic_ns()
|
||||
normalized_s_parameters = {
|
||||
str(name).lower(): np.asarray(values, dtype=np.complex64)
|
||||
for name, values in result.s_parameters.items()
|
||||
@@ -356,6 +361,11 @@ class MultiDeviceLibreVnaService:
|
||||
frequency_hz=frequencies,
|
||||
s11=reflection,
|
||||
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
|
||||
# Every combo comes out of the same synchronized cycle, so
|
||||
# they all share one window — no combo was measured earlier
|
||||
# or later than another here.
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=sweep_end_ns,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -368,6 +378,7 @@ class MultiDeviceLibreVnaService:
|
||||
|
||||
def _acquire_mock_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
|
||||
assert self._sweep_configuration is not None
|
||||
mock_sweep_start_ns = time.monotonic_ns()
|
||||
points = int(self._sweep_configuration.points)
|
||||
frequencies = np.linspace(
|
||||
self._sweep_configuration.start_hz,
|
||||
@@ -393,6 +404,8 @@ class MultiDeviceLibreVnaService:
|
||||
frequency_hz=frequencies,
|
||||
s11=s11,
|
||||
s21=s21,
|
||||
capture_start_ns=mock_sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
self._mock_phase += 0.05
|
||||
|
||||
@@ -183,7 +183,11 @@ class Sn9000Service:
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
|
||||
s_parameters = self._query_sweep_s_parameters(points)
|
||||
traces = self._assemble_traces(s_parameters)
|
||||
traces = self._assemble_traces(
|
||||
s_parameters,
|
||||
sweep_start_ns=capture_start_ns,
|
||||
sweep_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
@@ -256,7 +260,13 @@ class Sn9000Service:
|
||||
def _uses_pyvisa_py_backend(self) -> bool:
|
||||
return self.visa_library == "@py" or self.visa_library.endswith("@py")
|
||||
|
||||
def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]:
|
||||
def _assemble_traces(
|
||||
self,
|
||||
s_parameters: dict[str, np.ndarray],
|
||||
*,
|
||||
sweep_start_ns: int,
|
||||
sweep_end_ns: int,
|
||||
) -> list[TraceData]:
|
||||
frequency_hz = self._require_frequency_axis()
|
||||
traces: list[TraceData] = []
|
||||
for output_position, output_port in enumerate(_OUTPUT_PORT_BY_INDEX):
|
||||
@@ -269,6 +279,10 @@ class Sn9000Service:
|
||||
frequency_hz=frequency_hz,
|
||||
s11=reflection,
|
||||
s21=transmission,
|
||||
# One triggered sweep produces every port pair at once, so
|
||||
# all combos share the sweep's window.
|
||||
capture_start_ns=int(sweep_start_ns),
|
||||
capture_end_ns=int(sweep_end_ns),
|
||||
)
|
||||
)
|
||||
return traces
|
||||
|
||||
@@ -66,6 +66,10 @@ class SwitchService:
|
||||
"""Switch to requested position."""
|
||||
self._driver.switch_to(position)
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""Return number of positions supported by the backend driver."""
|
||||
return self._driver.position_count()
|
||||
|
||||
@property
|
||||
def current_position(self) -> int:
|
||||
"""Return current switch position reported by backend driver."""
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Matrix radar behind real GPIO switches on the stimulus and/or receiver path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
import logging
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.matrix_radar_service import MatrixRadarService
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RadarSweepModel, SwitchModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwitchedMatrixRadarService:
|
||||
"""Widen a matrix radar's combo matrix with real switch positions.
|
||||
|
||||
Implements the ``MatrixRadarService`` protocol, so the producer and the
|
||||
capture workflows treat it as an ordinary matrix radar that simply reports
|
||||
more positions. The hardware sweep is never stopped: switches are only ever
|
||||
driven BETWEEN ``acquire_collection`` calls, and the inner service's
|
||||
free-running collection discards any partially swept cycle.
|
||||
"""
|
||||
|
||||
inner: MatrixRadarService
|
||||
output_switch: SwitchService | None
|
||||
input_switch: SwitchService | None
|
||||
inner_output_positions: int
|
||||
inner_input_positions: int
|
||||
settling_ms: int = 0
|
||||
# Monotonic end of the previous inner collection, so the DEBUG timing trace can
|
||||
# report how long the gap between "sweep collected" and "switch driven" really is
|
||||
# — that gap is where a stale in-flight point 0 can still slip past the drain.
|
||||
_last_inner_end_ns: int = field(init=False, default=0, repr=False)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the inner radar and both switches."""
|
||||
self.inner.open()
|
||||
if self.output_switch is not None:
|
||||
self.output_switch.open()
|
||||
if self.input_switch is not None:
|
||||
self.input_switch.open()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close switches first, then the inner radar; never raises."""
|
||||
for switch in (self.input_switch, self.output_switch):
|
||||
if switch is not None:
|
||||
try:
|
||||
switch.close()
|
||||
except Exception as exc: # noqa: BLE001 — shutdown path
|
||||
logger.warning("Switch close ignored error: %s", exc)
|
||||
self.inner.close()
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Apply sweep settings to the inner radar."""
|
||||
self.inner.configure(sweep)
|
||||
|
||||
def recover(self) -> None:
|
||||
"""Reconnect the inner radar; switches are not on the USB transport."""
|
||||
self.inner.recover()
|
||||
|
||||
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
|
||||
"""Acquire the full widened matrix, one inner collection per switch step.
|
||||
|
||||
A partial failure raises instead of returning a short collection: the
|
||||
preprocessor requires every runtime combo to be present, so half a matrix
|
||||
is worse than a dropped frame.
|
||||
"""
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
|
||||
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
|
||||
total_inputs = in_steps * self.inner_input_positions
|
||||
total_outputs = out_steps * self.inner_output_positions
|
||||
|
||||
# Place each trace at its canonical index rather than appending. The GPR stage
|
||||
# rejects a collection whose trace order differs from run.combos, and run.combos
|
||||
# is built output-major (`build_full_combos`) while these loops run switch-major.
|
||||
# Appending happens to agree for an output switch and to disagree for an input one.
|
||||
slots: list[TraceData | None] = [None] * (total_inputs * total_outputs)
|
||||
|
||||
for out_k in range(out_steps):
|
||||
for in_k in range(in_steps):
|
||||
for trace in self._acquire_step_traces(out_k, in_k, collection_id):
|
||||
slots[trace.combo.output * total_inputs + trace.combo.input] = trace
|
||||
|
||||
if any(trace is None for trace in slots):
|
||||
missing = sum(1 for trace in slots if trace is None)
|
||||
raise RuntimeError(
|
||||
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
|
||||
)
|
||||
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=[trace for trace in slots if trace is not None],
|
||||
capture_start_ns=capture_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def acquire_combo_collection(
|
||||
self,
|
||||
*,
|
||||
input_pos: int,
|
||||
output_pos: int,
|
||||
collection_id: int = 1,
|
||||
) -> SweepCollection:
|
||||
"""Acquire only the physical switch step that carries one widened combo.
|
||||
|
||||
The per-combo capture workflows need a single trace at a time; sweeping
|
||||
every switch position for that (a full ``acquire_collection``) multiplies
|
||||
the capture time by the number of physical steps and freezes the caller
|
||||
for the whole sweep. One widened combo lives entirely inside one
|
||||
(out_k, in_k) step, so acquiring just that step is sufficient. The result
|
||||
contains that step's traces with widened combo keys, including the
|
||||
requested combo.
|
||||
"""
|
||||
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
|
||||
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
|
||||
total_inputs = in_steps * self.inner_input_positions
|
||||
total_outputs = out_steps * self.inner_output_positions
|
||||
if not (0 <= int(input_pos) < total_inputs and 0 <= int(output_pos) < total_outputs):
|
||||
raise ValueError(
|
||||
f"Widened combo out of range: input={input_pos} (of {total_inputs}), "
|
||||
f"output={output_pos} (of {total_outputs})"
|
||||
)
|
||||
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
out_k = int(output_pos) // self.inner_output_positions
|
||||
in_k = int(input_pos) // self.inner_input_positions
|
||||
traces = self._acquire_step_traces(out_k, in_k, collection_id)
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=traces,
|
||||
capture_start_ns=capture_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def _acquire_step_traces(self, out_k: int, in_k: int, collection_id: int) -> list[TraceData]:
|
||||
"""Drive both switches to one step, settle, and collect its widened traces.
|
||||
|
||||
Every returned trace carries the monotonic window of the inner collection
|
||||
that produced it, so a consumer can tell when each combo of a switched
|
||||
matrix was really measured instead of only when the whole cycle began and
|
||||
ended. The switch drive and settling are deliberately outside the window.
|
||||
"""
|
||||
step_start_ns = time.monotonic_ns()
|
||||
if self.output_switch is not None:
|
||||
self.output_switch.switch_to(out_k)
|
||||
if self.input_switch is not None:
|
||||
self.input_switch.switch_to(in_k)
|
||||
switched_ns = time.monotonic_ns()
|
||||
# Settle AFTER the last switch change and BEFORE collecting, so the
|
||||
# cycle we anchor on starts with the RF path already stable.
|
||||
if self.settling_ms > 0:
|
||||
time.sleep(self.settling_ms / 1000.0)
|
||||
settled_ns = time.monotonic_ns()
|
||||
|
||||
sub = self.inner.acquire_collection(collection_id)
|
||||
inner_end_ns = time.monotonic_ns()
|
||||
logger.debug(
|
||||
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
|
||||
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
|
||||
collection_id,
|
||||
out_k,
|
||||
in_k,
|
||||
(
|
||||
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
|
||||
if self._last_inner_end_ns
|
||||
else "n/a"
|
||||
),
|
||||
(switched_ns - step_start_ns) / 1e6,
|
||||
(settled_ns - switched_ns) / 1e6,
|
||||
(inner_end_ns - settled_ns) / 1e6,
|
||||
)
|
||||
self._last_inner_end_ns = inner_end_ns
|
||||
return [
|
||||
replace(
|
||||
trace,
|
||||
combo=ComboKey(
|
||||
input=in_k * self.inner_input_positions + int(trace.combo.input),
|
||||
output=out_k * self.inner_output_positions + int(trace.combo.output),
|
||||
),
|
||||
# Keep the inner service's own per-trace window when it reports one
|
||||
# (it knows its internal port order better than this step does);
|
||||
# otherwise fall back to the window of this inner collection.
|
||||
capture_start_ns=int(trace.capture_start_ns) or settled_ns,
|
||||
capture_end_ns=int(trace.capture_end_ns) or inner_end_ns,
|
||||
)
|
||||
for trace in sub.traces
|
||||
]
|
||||
|
||||
|
||||
def build_physical_switch(
|
||||
model: SwitchModel,
|
||||
physical_positions: int,
|
||||
radar_driver_mode: str,
|
||||
) -> SwitchService | None:
|
||||
"""Build the driver for a real switch described by a virtual switch section.
|
||||
|
||||
The config section carries the LOGICAL axis size and a forced "mock" mode so
|
||||
the C++ loader accepts it; the real driver needs the PHYSICAL position count
|
||||
and native mode. Mock radar runs keep mock switches so the whole path can be
|
||||
exercised without GPIO.
|
||||
"""
|
||||
if physical_positions <= 1:
|
||||
return None
|
||||
driver_mode = "mock" if radar_driver_mode.strip().lower() == "mock" else "native"
|
||||
return SwitchService.from_model(
|
||||
replace(model, positions=physical_positions, driver_mode=driver_mode)
|
||||
)
|
||||
@@ -32,12 +32,22 @@ class ComboKey:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceData:
|
||||
"""One frequency-domain trace set for a specific switch combination."""
|
||||
"""One frequency-domain trace set for a specific switch combination.
|
||||
|
||||
``capture_start_ns``/``capture_end_ns`` bound the monotonic window in which
|
||||
THIS trace's sweep was measured, excluding the switch drive and settling that
|
||||
preceded it. In switched modes a collection is assembled combo by combo over
|
||||
many milliseconds, so the collection-level window says nothing about when any
|
||||
individual combo was measured — these do. Zero on both means the producer did
|
||||
not report per-trace timing.
|
||||
"""
|
||||
|
||||
combo: ComboKey
|
||||
frequency_hz: np.ndarray
|
||||
s11: np.ndarray
|
||||
s21: np.ndarray
|
||||
capture_start_ns: int = 0
|
||||
capture_end_ns: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -244,6 +244,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
gui.processing.bscan.subtract_mean_ascan,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
history_window_scans=_optional_int(
|
||||
bscan_object,
|
||||
"history_window_scans",
|
||||
gui.processing.bscan.history_window_scans,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
),
|
||||
gpr=GuiGprStateModel(
|
||||
input_positions=_optional_string(
|
||||
@@ -579,6 +585,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"start_freq_mhz": gui.processing.bscan.start_freq_mhz,
|
||||
"stop_freq_mhz": gui.processing.bscan.stop_freq_mhz,
|
||||
"subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan,
|
||||
"history_window_scans": gui.processing.bscan.history_window_scans,
|
||||
},
|
||||
"gpr": {
|
||||
"input_positions": gui.processing.gpr.input_positions,
|
||||
|
||||
@@ -48,6 +48,10 @@ class GuiBscanStateModel:
|
||||
start_freq_mhz: float = 100.0
|
||||
stop_freq_mhz: float = 8800.0
|
||||
subtract_mean_ascan: bool = False
|
||||
# How many past sweeps the B-scan heatmap renders once acquisition is stopped.
|
||||
# While running the window stays at the C++ replay window (see
|
||||
# `_cpp_bscan_replay_window_for_config`); this only widens the stopped-mode view.
|
||||
history_window_scans: int = 50
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -219,6 +219,16 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
"recovery_attempts",
|
||||
model.radar.multi_device.recovery_attempts,
|
||||
)
|
||||
model.radar.multi_device.output_switch_positions = _read_int(
|
||||
multi_device_payload,
|
||||
"output_switch_positions",
|
||||
model.radar.multi_device.output_switch_positions,
|
||||
)
|
||||
model.radar.multi_device.input_switch_positions = _read_int(
|
||||
multi_device_payload,
|
||||
"input_switch_positions",
|
||||
model.radar.multi_device.input_switch_positions,
|
||||
)
|
||||
model.radar.kamil_adc.project_dir = _read_str(
|
||||
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
|
||||
)
|
||||
@@ -498,6 +508,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"slave_serials": list(model.radar.multi_device.slave_serials),
|
||||
"force_external_reference": model.radar.multi_device.force_external_reference,
|
||||
"recovery_attempts": model.radar.multi_device.recovery_attempts,
|
||||
"output_switch_positions": model.radar.multi_device.output_switch_positions,
|
||||
"input_switch_positions": model.radar.multi_device.input_switch_positions
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": model.radar.kamil_adc.project_dir,
|
||||
|
||||
@@ -40,6 +40,8 @@ class RadarMultiDeviceModel:
|
||||
slave_serials: list[str] = field(default_factory=list)
|
||||
force_external_reference: bool = True
|
||||
recovery_attempts: int = 3
|
||||
output_switch_positions: int = 1 # 1 = свитча нет
|
||||
input_switch_positions: int = 1 # 1 = свитча нет
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -390,6 +392,24 @@ class RunConfigModel:
|
||||
"""Return whether this config acquires the full virtual switch matrix per sweep."""
|
||||
return self.is_multi_device or self.is_sn9000
|
||||
|
||||
@property
|
||||
def matrix_output_switch_positions(self) -> int:
|
||||
"""Physical positions of the real switch on the master stimulus path."""
|
||||
if not self.is_multi_device:
|
||||
return 1
|
||||
return max(1, int(self.radar.multi_device.output_switch_positions))
|
||||
|
||||
@property
|
||||
def matrix_input_switch_positions(self) -> int:
|
||||
"""Physical positions of the real switch on the slave receiver path."""
|
||||
if not self.is_multi_device:
|
||||
return 1
|
||||
return max(1, int(self.radar.multi_device.input_switch_positions))
|
||||
|
||||
def build_runtime_combos(self) -> list[ComboModel]:
|
||||
"""Build the combo matrix from the effective switch axis sizes."""
|
||||
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
|
||||
|
||||
@property
|
||||
def is_kamil_adc(self) -> bool:
|
||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
||||
@@ -446,22 +466,25 @@ class RunConfigModel:
|
||||
if not self.is_matrix_radar:
|
||||
return
|
||||
self._apply_matrix_virtual_switches()
|
||||
self.combos = self.build_matrix_radar_virtual_combos()
|
||||
self.combos = self.build_runtime_combos()
|
||||
|
||||
def _apply_matrix_virtual_switches(self) -> None:
|
||||
"""Pin the canonical 2x4 virtual switch matrix used by all matrix-mode radars."""
|
||||
"""Pin the virtual switch matrix, widened by any real switch on the path."""
|
||||
out_physical = self.matrix_output_switch_positions
|
||||
in_physical = self.matrix_input_switch_positions
|
||||
|
||||
self.output_switch.name = self.output_switch.name or "virtual_output"
|
||||
self.output_switch.driver_mode = "mock"
|
||||
self.output_switch.driver = self.output_switch.driver or "h7992"
|
||||
self.output_switch.radar_port = 1
|
||||
self.output_switch.positions = self.MULTI_DEVICE_OUTPUT_POSITIONS
|
||||
self.output_switch.positions = out_physical * self.MULTI_DEVICE_OUTPUT_POSITIONS
|
||||
self.output_switch.default_position = 0
|
||||
|
||||
self.input_switch.name = self.input_switch.name or "virtual_input"
|
||||
self.input_switch.driver_mode = "mock"
|
||||
self.input_switch.driver = self.input_switch.driver or "h7992"
|
||||
self.input_switch.radar_port = 2
|
||||
self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS
|
||||
self.input_switch.positions = in_physical * self.MULTI_DEVICE_INPUT_POSITIONS
|
||||
self.input_switch.default_position = 0
|
||||
|
||||
def ensure_combos(self) -> None:
|
||||
|
||||
@@ -105,7 +105,10 @@ def _read_log_tail(path: Path, max_bytes: int = 16384) -> str:
|
||||
data = handle.read()
|
||||
except OSError:
|
||||
return ""
|
||||
return data.decode("utf-8", errors="replace").strip()
|
||||
# Drop NULs: logs written by an older supervisor can carry a sparse hole from
|
||||
# the pre-O_APPEND truncate bug, and a tail landing in it would otherwise turn
|
||||
# an exit report (or a rolled `.prev`) into megabytes of NUL padding.
|
||||
return data.replace(b"\0", b"").decode("utf-8", errors="replace").strip()
|
||||
|
||||
|
||||
class ProcessSupervisor:
|
||||
@@ -232,8 +235,17 @@ class ProcessSupervisor:
|
||||
self._roll_log_to_prev(stdout_path)
|
||||
self._roll_log_to_prev(stderr_path)
|
||||
|
||||
stdout_file = open(stdout_path, "wb")
|
||||
stderr_file = open(stderr_path, "wb")
|
||||
# O_APPEND ("ab"), not "wb": the child inherits these fds and keeps its own
|
||||
# file offset. Without O_APPEND, the in-place truncate in
|
||||
# `_roll_log_if_oversized` leaves that offset far past the new end of file,
|
||||
# so the next write lands there and the kernel fills everything before it
|
||||
# with a hole of NUL bytes — the log becomes unreadable and the size cap
|
||||
# stops working entirely. O_APPEND makes the kernel seek to EOF atomically
|
||||
# on every write, so a truncate genuinely restarts the file at offset 0.
|
||||
# `_roll_log_to_prev` above already renamed any previous log away, so not
|
||||
# truncating on open costs nothing.
|
||||
stdout_file = open(stdout_path, "ab")
|
||||
stderr_file = open(stderr_path, "ab")
|
||||
try:
|
||||
handle = subprocess.Popen(
|
||||
command,
|
||||
@@ -499,6 +511,11 @@ class ProcessSupervisor:
|
||||
The child holds an open fd to this inode, so a rename would not redirect
|
||||
its writes. Instead keep one rolled generation via copy-to-`.prev` and
|
||||
truncate the live inode in place, freeing the allocated disk blocks.
|
||||
|
||||
This relies on the child's fd being opened with O_APPEND (see `_spawn`):
|
||||
only then does the child resume writing at offset 0 after the truncate.
|
||||
With a plain write fd it would keep writing at its stale offset, punching
|
||||
a multi-hundred-megabyte NUL hole and defeating the cap.
|
||||
"""
|
||||
try:
|
||||
if path.stat().st_size <= _LOG_MAX_BYTES:
|
||||
|
||||
@@ -54,12 +54,27 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect
|
||||
)
|
||||
)
|
||||
|
||||
# Optional trailer, written after the trace blocks by newer producers: the
|
||||
# collection capture window, then a per-trace window table. Both stages are
|
||||
# optional so payloads from an older producer still decode (the timestamps
|
||||
# simply stay zero).
|
||||
capture_start_ns = 0
|
||||
capture_end_ns = 0
|
||||
if cursor.remaining_bytes() == 16:
|
||||
if cursor.remaining_bytes() != 0:
|
||||
if cursor.remaining_bytes() < 16:
|
||||
raise ValueError("Truncated capture window in trace collection")
|
||||
capture_start_ns = cursor.read_u64()
|
||||
capture_end_ns = cursor.read_u64()
|
||||
elif cursor.remaining_bytes() != 0:
|
||||
|
||||
if cursor.remaining_bytes() != 0:
|
||||
trace_time_count = cursor.read_u32()
|
||||
if trace_time_count != len(traces):
|
||||
raise ValueError("Per-trace capture window count does not match trace count")
|
||||
for trace in traces:
|
||||
trace.capture_start_ns = cursor.read_u64()
|
||||
trace.capture_end_ns = cursor.read_u64()
|
||||
|
||||
if cursor.remaining_bytes() != 0:
|
||||
raise ValueError("Unexpected trailing bytes in trace collection")
|
||||
|
||||
return SweepCollection(
|
||||
|
||||
@@ -23,6 +23,9 @@ class TraceRecord:
|
||||
stage_index: int
|
||||
frequency_hz: np.ndarray
|
||||
samples: np.ndarray
|
||||
# End of this trace's own sweep, from the snapshot's per-trace metadata; 0 for a
|
||||
# snapshot recorded before per-trace timing existed.
|
||||
capture_end_ns: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -128,6 +131,7 @@ def _load_stage_records(
|
||||
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
|
||||
frequency_hz=frequency_hz,
|
||||
samples=samples,
|
||||
capture_end_ns=int(trace_meta.get("capture_end_ns", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -214,7 +218,15 @@ def _build_sweep_history(
|
||||
|
||||
start_freq_hz = float(base.frequency_hz[0])
|
||||
stop_freq_hz = float(base.frequency_hz[-1])
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
||||
# Prefer this trace's own sweep time: with a switching matrix the combos of
|
||||
# one collection are measured milliseconds apart, so the collection
|
||||
# timestamp misplaces every combo but the last.
|
||||
if base.capture_end_ns > 0:
|
||||
timestamp_sec = float(base.capture_end_ns) / 1_000_000_000.0
|
||||
elif base.monotonic_ns > 0:
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0
|
||||
else:
|
||||
timestamp_sec = float(fallback_index)
|
||||
|
||||
history.append(
|
||||
{
|
||||
|
||||
@@ -184,12 +184,16 @@ def main() -> int:
|
||||
if collector_driven:
|
||||
# The collector already switched and tagged the sweep; just
|
||||
# read the clean capture for this combination.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = radar.acquire(combo=(combo.input, combo.output))
|
||||
else:
|
||||
output_switch.switch_to(combo.output)
|
||||
input_switch.switch_to(combo.input)
|
||||
if config.runtime.settling_ms > 0:
|
||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||
# Stamped after switching and settling so the window covers
|
||||
# the sweep alone, not the dead time before it.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = radar.acquire()
|
||||
traces.append(
|
||||
TraceData(
|
||||
@@ -197,6 +201,8 @@ def main() -> int:
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
||||
|
||||
@@ -11,6 +11,7 @@ import threading
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
|
||||
from python_app.logging_setup import coerce_level
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.shm import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
|
||||
@@ -95,6 +96,10 @@ def main() -> int:
|
||||
|
||||
config = RunConfigModel.load_from_path(args.config)
|
||||
config.apply_device_model_constraints()
|
||||
# Honor the configured verbosity so the DEBUG switch/sweep timing trace can be
|
||||
# turned on from the profile instead of requiring a code edit. basicConfig above
|
||||
# only installed the handler; the package logger owns the level.
|
||||
logging.getLogger("python_app").setLevel(coerce_level(config.logging.level))
|
||||
if not config.is_matrix_radar:
|
||||
raise RuntimeError(
|
||||
"matrix_raw_producer requires a matrix-mode radar.model "
|
||||
|
||||
@@ -22,7 +22,14 @@ def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
|
||||
|
||||
|
||||
def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes:
|
||||
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format."""
|
||||
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format.
|
||||
|
||||
The trailer is appended after the trace blocks so older readers, which stop at
|
||||
the last block, still decode the traces: first the collection capture window,
|
||||
then a per-trace window table (one ``(start_ns, end_ns)`` pair per trace, in
|
||||
trace order). See :func:`python_app.orchestration.shm.decoder.decode_trace_collection`
|
||||
and ``read_trace_collection`` in ``common_cpp/ipc/src/shared_types.cpp``.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
buffer.extend(struct.pack("<IQQI", magic, collection.collection_id, collection.monotonic_ns, len(collection.traces)))
|
||||
|
||||
@@ -48,6 +55,11 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
|
||||
int(collection.capture_end_ns),
|
||||
)
|
||||
)
|
||||
buffer.extend(struct.pack("<I", len(collection.traces)))
|
||||
for trace in collection.traces:
|
||||
buffer.extend(
|
||||
struct.pack("<QQ", int(trace.capture_start_ns), int(trace.capture_end_ns))
|
||||
)
|
||||
return bytes(buffer)
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,17 @@ def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], m
|
||||
"capture_start_ns": int(collection.capture_start_ns),
|
||||
"capture_end_ns": int(collection.capture_end_ns),
|
||||
"trace_count": len(collection.traces),
|
||||
# Also in the .bin trailer; repeated here so per-combo timing is
|
||||
# readable without decoding the binary payload.
|
||||
"traces": [
|
||||
{
|
||||
"input": int(trace.combo.input),
|
||||
"output": int(trace.combo.output),
|
||||
"capture_start_ns": int(trace.capture_start_ns),
|
||||
"capture_end_ns": int(trace.capture_end_ns),
|
||||
}
|
||||
for trace in collection.traces
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
@@ -182,6 +193,10 @@ def save_trace_history_numpy(
|
||||
"input": int(trace.combo.input),
|
||||
"output": int(trace.combo.output),
|
||||
"points": int(freq.size),
|
||||
# When each combo was measured, which in a switched matrix is
|
||||
# spread across the collection window rather than aligned with it.
|
||||
"capture_start_ns": int(trace.capture_start_ns),
|
||||
"capture_end_ns": int(trace.capture_end_ns),
|
||||
"freq_file": f"{tag}_freq.npy",
|
||||
"s11_file": f"{tag}_s11.npy",
|
||||
"s21_file": f"{tag}_s21.npy",
|
||||
|
||||
@@ -117,6 +117,8 @@ class NpzStore(StoreApi):
|
||||
{
|
||||
"input": trace.combo.input,
|
||||
"output": trace.combo.output,
|
||||
"capture_start_ns": int(trace.capture_start_ns),
|
||||
"capture_end_ns": int(trace.capture_end_ns),
|
||||
"freq_key": freq_key,
|
||||
"s11_key": s11_key,
|
||||
"s21_key": s21_key,
|
||||
@@ -177,6 +179,8 @@ class NpzStore(StoreApi):
|
||||
frequency_hz=freq,
|
||||
s11=s11,
|
||||
s21=s21,
|
||||
capture_start_ns=int(combo.get("capture_start_ns", 0)),
|
||||
capture_end_ns=int(combo.get("capture_end_ns", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ class TraceRecord:
|
||||
stage_index: int
|
||||
frequency_hz: np.ndarray
|
||||
samples: np.ndarray
|
||||
# End of this trace's own sweep, or 0 when the producer reported no per-trace
|
||||
# timing. Preferred over the collection timestamp for the exported sweep time:
|
||||
# in a switched matrix each combo is measured at a different instant.
|
||||
capture_end_ns: int = 0
|
||||
|
||||
|
||||
def _normalize_channel(channel: str) -> str:
|
||||
@@ -85,6 +89,7 @@ def _build_stage_records(
|
||||
stage_index=int(stage_index),
|
||||
frequency_hz=frequency_hz,
|
||||
samples=samples,
|
||||
capture_end_ns=int(trace.capture_end_ns),
|
||||
)
|
||||
)
|
||||
return records
|
||||
@@ -137,7 +142,15 @@ def _build_sweep_history(
|
||||
|
||||
start_freq_hz = float(base.frequency_hz[0])
|
||||
stop_freq_hz = float(base.frequency_hz[-1])
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
||||
# Prefer the exported trace's own sweep time: with a switching matrix the
|
||||
# combos of one collection are measured milliseconds apart, so the
|
||||
# collection timestamp misplaces every combo but the last.
|
||||
if base.capture_end_ns > 0:
|
||||
timestamp_sec = float(base.capture_end_ns) / 1_000_000_000.0
|
||||
elif base.monotonic_ns > 0:
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0
|
||||
else:
|
||||
timestamp_sec = float(fallback_index)
|
||||
|
||||
history.append(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Configurable B-scan display window.
|
||||
|
||||
The B-scan used to be pinned to the C++ replay window (~50 sweeps) by two separate
|
||||
mechanisms: the render-side history limit and a monotonically rising
|
||||
`collection_id` floor. Widening only the first would have changed nothing, because
|
||||
the floor kept filtering older collections out for good.
|
||||
|
||||
The floor is gone: selection is positional, which is the only criterion that holds
|
||||
when ids are sparse (the results ring drops) or restart from 1 (a new C++ run).
|
||||
These tests pin the observable consequences — how many columns end up on screen —
|
||||
rather than any internal counter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from python_app.gui.app_window import AppWindow # noqa: E402
|
||||
from python_app.gui.runtime.history import record_result_history # noqa: E402
|
||||
from python_app.models.dataset_model import ( # noqa: E402
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
)
|
||||
|
||||
_app: QApplication | None = None
|
||||
_window: AppWindow | None = None
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app, _window
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
_window = AppWindow(Path("."))
|
||||
|
||||
|
||||
def tearDownModule() -> None:
|
||||
if _window is not None:
|
||||
_window.close()
|
||||
|
||||
|
||||
def _bscan_result(collection_id: int) -> ResultCollection:
|
||||
payload = ResultPayload(
|
||||
processing_name="bscan",
|
||||
kind=1,
|
||||
frequency_hz=np.array([0.5, 1.0], dtype=np.float32),
|
||||
trace=np.array([collection_id + 0j, collection_id + 0j], dtype=np.complex64),
|
||||
)
|
||||
block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])
|
||||
return ResultCollection(collection_id=collection_id, monotonic_ns=collection_id, blocks=[block])
|
||||
|
||||
|
||||
class BscanDisplayWindowTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.w = _window
|
||||
self._original_is_running = self.w._supervisor.is_running
|
||||
self.w._result_history.clear()
|
||||
self.w._bscan_render_signature = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.w._supervisor.is_running = self._original_is_running
|
||||
self.w._result_history.clear()
|
||||
self.w._bscan_render_signature = None
|
||||
|
||||
def _set_running(self, running: bool) -> None:
|
||||
self.w._supervisor.is_running = lambda: running
|
||||
|
||||
def _fill_history(self, count: int, *, id_step: int = 1) -> None:
|
||||
for index in range(count):
|
||||
self.w._result_history.append(_bscan_result(1 + index * id_step))
|
||||
|
||||
def test_running_acquisition_ignores_the_user_window(self) -> None:
|
||||
# A live rebuild runs on every incoming result, so the live path stays pinned
|
||||
# to the replay window no matter what the operator typed for stopped review.
|
||||
self._set_running(True)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.assertEqual(self.w._bscan_display_window_scans(), self.w._bscan_cpp_replay_window)
|
||||
|
||||
def test_stopped_acquisition_uses_the_user_window(self) -> None:
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.assertEqual(self.w._bscan_display_window_scans(), 300)
|
||||
|
||||
def test_widening_the_window_brings_older_frames_back(self) -> None:
|
||||
# The regression this whole change exists for: a live run renders 50 columns,
|
||||
# and widening the window after Stop must reach back over the retained history
|
||||
# rather than stay pinned to whatever the live path last drew.
|
||||
self._fill_history(300)
|
||||
self._set_running(True)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(
|
||||
len(self.w._bscan_history_by_combo[(0, 0)]), self.w._bscan_cpp_replay_window
|
||||
)
|
||||
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
|
||||
|
||||
def test_restarted_collection_ids_do_not_hide_the_fresh_run(self) -> None:
|
||||
# Regression: Start after a stopped review made the image melt to 0 columns and
|
||||
# then snap back to 50. A new C++ run numbers from 1, so entries that are newest
|
||||
# by position carry the smallest ids; any id-based cut-off derived from the old
|
||||
# run rejected exactly them.
|
||||
self._fill_history(300)
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
|
||||
self._set_running(True)
|
||||
window = self.w._bscan_cpp_replay_window
|
||||
for fresh in range(1, window + 1):
|
||||
self.w._result_history.append(_bscan_result(fresh))
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
|
||||
|
||||
def test_window_counts_entries_not_collection_ids(self) -> None:
|
||||
# The results ring overwrites unread slots when the producer outruns the GUI
|
||||
# poll loop, so retained collection ids are sparse. Counting in ids rather than
|
||||
# in entries is exactly the case where asking for 150 sweeps rendered only 87.
|
||||
self._fill_history(300, id_step=3)
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(150)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 150)
|
||||
|
||||
def test_reprocessed_results_replace_rather_than_duplicate_columns(self) -> None:
|
||||
# The processor's recompute is triggered by feeding sweeps back through it, and
|
||||
# it republishes them under their ORIGINAL ids. Two independent mechanisms keep
|
||||
# that from doubling every column, and neither may be confused with the removed
|
||||
# `collection_id` floor: a threshold cannot tell a duplicate from its original,
|
||||
# since they share the id. Deduplication is by key equality.
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self._fill_history(200)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
|
||||
|
||||
# Intake: a recomputed collection replaces the entry holding the same key.
|
||||
for collection in list(self.w._result_history):
|
||||
record_result_history(self.w._result_history, _bscan_result(collection.collection_id))
|
||||
self.assertEqual(len(self.w._result_history), 200)
|
||||
|
||||
# Render: a duplicate that reached the deque by another path is still collapsed,
|
||||
# keeping the newer of the two.
|
||||
self.w._result_history.append(_bscan_result(200))
|
||||
self.w._bscan_render_signature = None
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
|
||||
|
||||
def test_rebuild_renders_the_full_widened_window(self) -> None:
|
||||
self._fill_history(300)
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
|
||||
|
||||
self.w._bscan_history_window.setValue(50)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Re-feeding retained sweeps to the processor to recompute the whole B-scan.
|
||||
|
||||
The processor keeps only ~50 preprocessed sweeps of its own, so a live settings edit
|
||||
used to refresh just the newest 50 columns. The GUI holds up to 1000 of them and hands
|
||||
them back through the processor's input ring, which only works if the bytes the GUI
|
||||
writes are exactly the ones the C++ side expects — that wire-format contract is what
|
||||
the first test pins, without needing the pipeline running.
|
||||
|
||||
The guard tests pin the other half: the GUI must never write into that ring while
|
||||
`data_preprocessor` owns it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PyQt6.QtCore import QSignalBlocker # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from python_app.gui.app_window import AppWindow # noqa: E402
|
||||
from python_app.models.dataset_model import ( # noqa: E402
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
SweepCollection,
|
||||
TraceData,
|
||||
)
|
||||
from python_app.orchestration.shm import ShmRingWriter # noqa: E402
|
||||
from python_app.orchestration.shm.ring_reader import ShmRingReader # noqa: E402
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection # noqa: E402
|
||||
|
||||
# Mirrors kPreprocessedCollectionMagic in common_cpp/ipc/src/shared_types.cpp. If this
|
||||
# ever drifts, the processor silently rejects everything the GUI re-feeds.
|
||||
_CPP_PREPROCESSED_MAGIC = 0x32525050
|
||||
|
||||
_app: QApplication | None = None
|
||||
_window: AppWindow | None = None
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app, _window
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
_window = AppWindow(Path("."))
|
||||
|
||||
|
||||
def tearDownModule() -> None:
|
||||
if _window is not None:
|
||||
_window.close()
|
||||
|
||||
|
||||
def _sweep(collection_id: int, *, combos: int = 3, points: int = 64) -> SweepCollection:
|
||||
traces = [
|
||||
TraceData(
|
||||
combo=ComboKey(input=index, output=index + 1),
|
||||
frequency_hz=np.linspace(1e9, 8e9, points, dtype=np.float32),
|
||||
s11=(np.arange(points) + index).astype(np.complex64) + 0.5j,
|
||||
s21=(np.arange(points) * 2 + index).astype(np.complex64) - 0.25j,
|
||||
)
|
||||
for index in range(combos)
|
||||
]
|
||||
return SweepCollection(
|
||||
collection_id=collection_id,
|
||||
monotonic_ns=collection_id * 1_000_000 + 7,
|
||||
traces=traces,
|
||||
capture_start_ns=collection_id * 10,
|
||||
capture_end_ns=collection_id * 10 + 5,
|
||||
)
|
||||
|
||||
|
||||
def _bscan_result(collection_id: int, *, points: int = 32) -> ResultCollection:
|
||||
payload = ResultPayload(
|
||||
processing_name="bscan",
|
||||
kind=1,
|
||||
frequency_hz=np.linspace(0.0, 1.0, points, dtype=np.float32),
|
||||
trace=(np.arange(points) + collection_id).astype(np.complex64),
|
||||
)
|
||||
return ResultCollection(
|
||||
collection_id=collection_id,
|
||||
monotonic_ns=collection_id,
|
||||
blocks=[ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])],
|
||||
)
|
||||
|
||||
|
||||
class PreprocessedWireFormatTest(unittest.TestCase):
|
||||
"""The bytes the GUI re-feeds must be the ones the C++ processor decodes."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.ring_name = f"/radar_test_{self._testMethodName}"
|
||||
with suppress(OSError):
|
||||
(Path("/dev/shm") / self.ring_name[1:]).unlink()
|
||||
self.writer = ShmRingWriter(self.ring_name, 8, 1 << 20)
|
||||
self.reader = ShmRingReader(self.ring_name)
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
with suppress(Exception):
|
||||
self.reader.close()
|
||||
with suppress(Exception):
|
||||
self.writer.close()
|
||||
with suppress(OSError):
|
||||
(Path("/dev/shm") / self.ring_name[1:]).unlink()
|
||||
|
||||
def _assert_same(self, original: SweepCollection, decoded: SweepCollection) -> None:
|
||||
self.assertEqual(decoded.collection_id, original.collection_id)
|
||||
self.assertEqual(decoded.monotonic_ns, original.monotonic_ns)
|
||||
self.assertEqual(decoded.capture_start_ns, original.capture_start_ns)
|
||||
self.assertEqual(decoded.capture_end_ns, original.capture_end_ns)
|
||||
self.assertEqual(len(decoded.traces), len(original.traces))
|
||||
for left, right in zip(original.traces, decoded.traces, strict=True):
|
||||
self.assertEqual((left.combo.input, left.combo.output), (right.combo.input, right.combo.output))
|
||||
np.testing.assert_array_equal(left.frequency_hz, right.frequency_hz)
|
||||
np.testing.assert_array_equal(left.s11, right.s11)
|
||||
np.testing.assert_array_equal(left.s21, right.s21)
|
||||
|
||||
def test_magic_matches_the_cpp_decoder(self) -> None:
|
||||
payload = serialize_trace_collection(_sweep(1), PREPROC_MAGIC)
|
||||
self.assertEqual(int.from_bytes(payload[:4], "little"), _CPP_PREPROCESSED_MAGIC)
|
||||
|
||||
def test_round_trip_preserves_every_field(self) -> None:
|
||||
original = _sweep(42)
|
||||
self.assertTrue(self.writer.push(serialize_trace_collection(original, PREPROC_MAGIC)))
|
||||
|
||||
decoded = self.reader.pop_preprocessed_collection()
|
||||
self.assertIsNotNone(decoded)
|
||||
assert decoded is not None
|
||||
self._assert_same(original, decoded)
|
||||
|
||||
def test_batch_keeps_order(self) -> None:
|
||||
originals = [_sweep(cid) for cid in range(100, 105)]
|
||||
for collection in originals:
|
||||
self.assertTrue(self.writer.push(serialize_trace_collection(collection, PREPROC_MAGIC)))
|
||||
|
||||
decoded = []
|
||||
while (collection := self.reader.pop_preprocessed_collection()) is not None:
|
||||
decoded.append(collection)
|
||||
|
||||
self.assertEqual([c.collection_id for c in decoded], [c.collection_id for c in originals])
|
||||
for left, right in zip(originals, decoded, strict=True):
|
||||
self._assert_same(left, right)
|
||||
|
||||
def test_overflow_drops_oldest(self) -> None:
|
||||
# Why the replay pushes in chunks instead of one burst: an undrained ring
|
||||
# silently overwrites, which would punch holes into the very image we are
|
||||
# trying to make coherent.
|
||||
capacity = 8
|
||||
for cid in range(200, 200 + capacity + 4):
|
||||
self.writer.push(serialize_trace_collection(_sweep(cid), PREPROC_MAGIC))
|
||||
|
||||
survived = []
|
||||
while (collection := self.reader.pop_preprocessed_collection()) is not None:
|
||||
survived.append(collection.collection_id)
|
||||
|
||||
self.assertEqual(len(survived), capacity)
|
||||
self.assertEqual(survived[-1], 200 + capacity + 3)
|
||||
|
||||
|
||||
class _IdleResultReader:
|
||||
"""Stands in for a connected results reader that simply has nothing to hand out."""
|
||||
|
||||
def pop_result_collection(self):
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class ReplayGuardTest(unittest.TestCase):
|
||||
"""The GUI must refuse to write into a ring `data_preprocessor` still owns."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.w = _window
|
||||
# The 50 ms ring poll and the debounce would both run against this half-faked
|
||||
# window while the test drives it by hand; park them for the duration.
|
||||
self.w._timer.stop()
|
||||
self.w._bscan_reprocess_timer.stop()
|
||||
self.addCleanup(self.w._timer.start)
|
||||
self.addCleanup(self.w._bscan_reprocess_timer.stop)
|
||||
|
||||
self._original_is_running = self.w._supervisor.is_running
|
||||
self._original_is_processor_running = self.w._supervisor.is_processor_running
|
||||
self._original_mode = self.w._processing_mode.currentText()
|
||||
# Changing the mode fires the live-settings handler, which would kick off the
|
||||
# very replay these tests are inspecting.
|
||||
with QSignalBlocker(self.w._processing_mode):
|
||||
self.w._processing_mode.setCurrentText("bscan")
|
||||
self.w._supervisor.is_running = lambda: False
|
||||
self.w._supervisor.is_processor_running = lambda: True
|
||||
self.w._result_reader = _IdleResultReader()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.w._supervisor.is_running = self._original_is_running
|
||||
self.w._supervisor.is_processor_running = self._original_is_processor_running
|
||||
with QSignalBlocker(self.w._processing_mode):
|
||||
self.w._processing_mode.setCurrentText(self._original_mode)
|
||||
self.w._result_reader = None
|
||||
|
||||
def test_refuses_while_acquisition_runs(self) -> None:
|
||||
self.w._supervisor.is_running = lambda: True
|
||||
self.assertFalse(self.w._can_reprocess_history())
|
||||
|
||||
def test_refuses_when_processor_is_down(self) -> None:
|
||||
self.w._supervisor.is_processor_running = lambda: False
|
||||
self.assertFalse(self.w._can_reprocess_history())
|
||||
|
||||
def test_refuses_outside_bscan_mode(self) -> None:
|
||||
self.w._processing_mode.setCurrentText("pass_through")
|
||||
self.assertFalse(self.w._can_reprocess_history())
|
||||
|
||||
def test_refuses_without_a_results_reader(self) -> None:
|
||||
self.w._result_reader = None
|
||||
self.assertFalse(self.w._can_reprocess_history())
|
||||
|
||||
def test_allows_when_stopped_with_a_live_processor(self) -> None:
|
||||
self.assertTrue(self.w._can_reprocess_history())
|
||||
|
||||
def test_replayed_results_are_rendered_instead_of_runtime_history(self) -> None:
|
||||
"""Regression: 300 re-sent, 300 recovered, only 145 drawn.
|
||||
|
||||
`_pre_history` and `_result_history` come from two rings that drop
|
||||
independently, so the re-sent sweeps only partly overlap the recorded results.
|
||||
The non-overlapping ones get appended to the deque under old ids, leaving its
|
||||
newest `window` entries a mix of freshly and stale-processed frames. Rendering
|
||||
from the replayed set instead sidesteps the whole problem.
|
||||
"""
|
||||
window = 30
|
||||
replayed = [_bscan_result(cid) for cid in range(9000, 9000 + window)]
|
||||
|
||||
self.w._result_history.clear()
|
||||
# Stand-in for a runtime history whose ids barely overlap the replayed ones.
|
||||
self.w._result_history.extend(_bscan_result(cid) for cid in range(1, 200))
|
||||
self.addCleanup(self.w._result_history.clear)
|
||||
|
||||
self.w._bscan_replay_results = replayed
|
||||
self.addCleanup(self.w._discard_bscan_replay_results)
|
||||
self.w._bscan_history_window.setValue(window)
|
||||
self.w._bscan_render_signature = None
|
||||
self.w._sync_bscan_history_from_results()
|
||||
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
|
||||
|
||||
def test_discarding_replay_falls_back_to_runtime_history(self) -> None:
|
||||
self.w._bscan_replay_results = [_bscan_result(1)]
|
||||
self.w._discard_bscan_replay_results()
|
||||
self.assertEqual(self.w._bscan_replay_results, [])
|
||||
self.assertIsNone(self.w._bscan_render_signature)
|
||||
|
||||
def test_replay_refuses_to_re_enter_itself(self) -> None:
|
||||
# Pumping the event loop mid-replay can fire the debounce again; a nested burst
|
||||
# would share the ring and corrupt the result accounting.
|
||||
self.w._pre_history.clear()
|
||||
self.w._pre_history.extend(_sweep(cid) for cid in range(1, 6))
|
||||
self.addCleanup(self.w._pre_history.clear)
|
||||
|
||||
self.w._bscan_replay_active = True
|
||||
self.addCleanup(setattr, self.w, "_bscan_replay_active", False)
|
||||
|
||||
self.assertFalse(self.w._reprocess_history_through_processor())
|
||||
|
||||
def test_replay_refuses_without_an_active_run_config(self) -> None:
|
||||
# A GUI restarted against a still-running processor does not know the ring
|
||||
# geometry, and ShmRingWriter would recreate the segment underneath it.
|
||||
self.w._pre_history.clear()
|
||||
self.w._pre_history.extend(_sweep(cid) for cid in range(1, 6))
|
||||
self.addCleanup(self.w._pre_history.clear)
|
||||
|
||||
previous = getattr(self.w, "_active_run_config", None)
|
||||
self.w._active_run_config = None
|
||||
self.addCleanup(setattr, self.w, "_active_run_config", previous)
|
||||
|
||||
self.assertIsNone(self.w._ensure_replay_ring_writer())
|
||||
self.assertFalse(self.w._reprocess_history_through_processor())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unit tests for the bounded GUI log-panel buffer.
|
||||
|
||||
The buffer decouples logging handlers (any thread, potentially very chatty at
|
||||
DEBUG) from the GUI: records are batched by a flush timer instead of posting one
|
||||
queued Qt event per record, and overflow drops the oldest records with a count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from python_app.gui.app_window import _PanelLogBuffer
|
||||
|
||||
|
||||
class PanelLogBufferTest(unittest.TestCase):
|
||||
"""Bounded capacity, oldest-first eviction, and accurate drop accounting."""
|
||||
|
||||
def test_drain_returns_entries_in_order_and_clears(self) -> None:
|
||||
buffer = _PanelLogBuffer()
|
||||
buffer.append("INFO", "first", None, None)
|
||||
buffer.append("WARN", "second", "details", "key")
|
||||
|
||||
entries, dropped_count = buffer.drain()
|
||||
|
||||
self.assertEqual(dropped_count, 0)
|
||||
self.assertEqual(
|
||||
entries,
|
||||
[("INFO", "first", None, None), ("WARN", "second", "details", "key")],
|
||||
)
|
||||
self.assertEqual(buffer.drain(), ([], 0))
|
||||
|
||||
def test_overflow_drops_oldest_and_counts(self) -> None:
|
||||
buffer = _PanelLogBuffer()
|
||||
overflow = 100
|
||||
total = _PanelLogBuffer._CAPACITY + overflow
|
||||
for index in range(total):
|
||||
buffer.append("DEBUG", f"m{index}", None, None)
|
||||
|
||||
entries, dropped_count = buffer.drain()
|
||||
|
||||
self.assertEqual(dropped_count, overflow)
|
||||
self.assertEqual(len(entries), _PanelLogBuffer._CAPACITY)
|
||||
self.assertEqual(entries[0][1], f"m{overflow}")
|
||||
self.assertEqual(entries[-1][1], f"m{total - 1}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -219,7 +219,7 @@ class RebuildBscanHistoryTest(unittest.TestCase):
|
||||
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)
|
||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10)
|
||||
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)))
|
||||
|
||||
@@ -229,7 +229,7 @@ class RebuildBscanHistoryTest(unittest.TestCase):
|
||||
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)
|
||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10)
|
||||
self.assertEqual(by_combo, {})
|
||||
|
||||
def test_depth_axis_change_resets_history(self) -> None:
|
||||
@@ -237,17 +237,21 @@ class RebuildBscanHistoryTest(unittest.TestCase):
|
||||
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)
|
||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10)
|
||||
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:
|
||||
def test_selection_is_positional_not_by_collection_id(self) -> None:
|
||||
# Ids are neither dense nor monotonic across a run boundary, so the tail is
|
||||
# taken by position only. Here the newest two entries carry the SMALLEST ids;
|
||||
# an id-based cut-off would have dropped exactly them.
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0], [10.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0], [20.0]),
|
||||
self._bscan_collection(cid, (0, 0), [1.0], [float(cid)])
|
||||
for cid in (98, 99, 100, 1, 2)
|
||||
]
|
||||
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
|
||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=3)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 3)
|
||||
self.assertEqual([sweep[0] for sweep in by_combo[(0, 0)]], [100.0, 1.0, 2.0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -39,12 +39,19 @@ 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:
|
||||
def _trace(in_pos: int, out_pos: int, n: int, *, capture_ns: tuple[int, int] = (0, 0)) -> 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)
|
||||
return TraceData(
|
||||
combo=ComboKey(input=in_pos, output=out_pos),
|
||||
frequency_hz=freq,
|
||||
s11=s11,
|
||||
s21=s21,
|
||||
capture_start_ns=capture_ns[0],
|
||||
capture_end_ns=capture_ns[1],
|
||||
)
|
||||
|
||||
|
||||
class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
@@ -52,7 +59,7 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
collection = SweepCollection(
|
||||
collection_id=7,
|
||||
monotonic_ns=123,
|
||||
traces=[_trace(0, 0, 4), _trace(3, 1, 2)],
|
||||
traces=[_trace(0, 0, 4, capture_ns=(11, 13)), _trace(3, 1, 2, capture_ns=(15, 19))],
|
||||
capture_start_ns=10,
|
||||
capture_end_ns=20,
|
||||
)
|
||||
@@ -66,6 +73,10 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
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))
|
||||
self.assertEqual(
|
||||
(got.capture_start_ns, got.capture_end_ns),
|
||||
(original.capture_start_ns, original.capture_end_ns),
|
||||
)
|
||||
|
||||
def test_raw_round_trips(self) -> None:
|
||||
self._assert_round_trips(RAW_MAGIC)
|
||||
@@ -78,6 +89,34 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
|
||||
self.assertEqual(decoded.traces, [])
|
||||
|
||||
def test_payload_without_per_trace_window_table_still_decodes(self) -> None:
|
||||
# A producer built before per-trace timing stops after the collection
|
||||
# window; its traces must still decode, with the timestamps left at zero.
|
||||
collection = SweepCollection(
|
||||
collection_id=4,
|
||||
monotonic_ns=5,
|
||||
traces=[_trace(1, 0, 3, capture_ns=(7, 9))],
|
||||
capture_start_ns=6,
|
||||
capture_end_ns=10,
|
||||
)
|
||||
full = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
legacy = full[: -(4 + 16 * len(collection.traces))]
|
||||
|
||||
decoded = decode_trace_collection(legacy, RAW_MAGIC)
|
||||
self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (6, 10))
|
||||
self.assertEqual(len(decoded.traces), 1)
|
||||
self.assertEqual((decoded.traces[0].capture_start_ns, decoded.traces[0].capture_end_ns), (0, 0))
|
||||
|
||||
def test_per_trace_window_count_mismatch_is_rejected(self) -> None:
|
||||
collection = SweepCollection(
|
||||
collection_id=4, monotonic_ns=5, traces=[_trace(1, 0, 3, capture_ns=(7, 9))]
|
||||
)
|
||||
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
# Overwrite the window-table count (u32 before the single 16-byte pair).
|
||||
corrupt = payload[:-20] + struct.pack("<I", 2) + payload[-16:]
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||
|
||||
|
||||
class ResultCollectionRoundTripTest(unittest.TestCase):
|
||||
def test_all_payload_kinds_round_trip(self) -> None:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Unit tests for switch-widened matrix capture.
|
||||
|
||||
Cover the targeted single-step acquisition on ``SwitchedMatrixRadarService`` and
|
||||
verify the manual per-combo capture workflow uses it instead of sweeping the full
|
||||
widened matrix (the regression that froze the GUI for the whole matrix per click).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
_INNER_INPUTS = 4
|
||||
_INNER_OUTPUTS = 2
|
||||
_POINTS = 8
|
||||
|
||||
|
||||
class _FakeInnerMatrixRadar:
|
||||
"""Matrix radar stub emitting the canonical 2x4 combo set per acquisition."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.acquire_count = 0
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def configure(self, sweep) -> None:
|
||||
pass
|
||||
|
||||
def recover(self) -> None:
|
||||
pass
|
||||
|
||||
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
|
||||
self.acquire_count += 1
|
||||
frequency_hz = np.linspace(1e6, 2e6, _POINTS, dtype=np.float32)
|
||||
traces = [
|
||||
TraceData(
|
||||
combo=ComboKey(input=input_pos, output=output_pos),
|
||||
frequency_hz=frequency_hz,
|
||||
s11=np.full(_POINTS, complex(self.acquire_count, 0), dtype=np.complex64),
|
||||
s21=np.full(_POINTS, complex(input_pos, output_pos), dtype=np.complex64),
|
||||
)
|
||||
for output_pos in range(_INNER_OUTPUTS)
|
||||
for input_pos in range(_INNER_INPUTS)
|
||||
]
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=traces,
|
||||
)
|
||||
|
||||
|
||||
class _FakeSwitch:
|
||||
"""Switch stub recording every position it is driven to."""
|
||||
|
||||
def __init__(self, positions: int) -> None:
|
||||
self.positions = positions
|
||||
self.switched_to: list[int] = []
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def position_count(self) -> int:
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
self.switched_to.append(int(position))
|
||||
|
||||
|
||||
def _switched_service(input_steps: int = 3) -> tuple[SwitchedMatrixRadarService, _FakeInnerMatrixRadar, _FakeSwitch]:
|
||||
inner = _FakeInnerMatrixRadar()
|
||||
input_switch = _FakeSwitch(input_steps)
|
||||
service = SwitchedMatrixRadarService(
|
||||
inner=inner,
|
||||
output_switch=None,
|
||||
input_switch=input_switch,
|
||||
inner_output_positions=_INNER_OUTPUTS,
|
||||
inner_input_positions=_INNER_INPUTS,
|
||||
settling_ms=0,
|
||||
)
|
||||
return service, inner, input_switch
|
||||
|
||||
|
||||
class SwitchedMatrixComboAcquisitionTest(unittest.TestCase):
|
||||
"""acquire_combo_collection must acquire exactly one physical switch step."""
|
||||
|
||||
def test_acquires_only_the_step_containing_the_combo(self) -> None:
|
||||
service, inner, input_switch = _switched_service(input_steps=3)
|
||||
|
||||
# Widened input 9 lives in physical step 9 // 4 = 2.
|
||||
collection = service.acquire_combo_collection(input_pos=9, output_pos=1)
|
||||
|
||||
self.assertEqual(inner.acquire_count, 1)
|
||||
self.assertEqual(input_switch.switched_to, [2])
|
||||
self.assertEqual(len(collection.traces), _INNER_INPUTS * _INNER_OUTPUTS)
|
||||
combos = {(trace.combo.input, trace.combo.output) for trace in collection.traces}
|
||||
self.assertIn((9, 1), combos)
|
||||
# Every trace of the step is remapped into the widened axis of that step.
|
||||
self.assertEqual(
|
||||
combos,
|
||||
{(2 * _INNER_INPUTS + i, o) for i in range(_INNER_INPUTS) for o in range(_INNER_OUTPUTS)},
|
||||
)
|
||||
|
||||
def test_rejects_out_of_range_combo(self) -> None:
|
||||
service, _inner, _input_switch = _switched_service(input_steps=3)
|
||||
with self.assertRaises(ValueError):
|
||||
service.acquire_combo_collection(input_pos=12, output_pos=0)
|
||||
with self.assertRaises(ValueError):
|
||||
service.acquire_combo_collection(input_pos=0, output_pos=2)
|
||||
|
||||
def test_full_collection_still_covers_widened_matrix_in_canonical_order(self) -> None:
|
||||
service, inner, input_switch = _switched_service(input_steps=3)
|
||||
|
||||
collection = service.acquire_collection(collection_id=7)
|
||||
|
||||
self.assertEqual(inner.acquire_count, 3)
|
||||
self.assertEqual(input_switch.switched_to, [0, 1, 2])
|
||||
expected_combos = [
|
||||
(input_pos, output_pos)
|
||||
for output_pos in range(_INNER_OUTPUTS)
|
||||
for input_pos in range(3 * _INNER_INPUTS)
|
||||
]
|
||||
self.assertEqual(
|
||||
[(trace.combo.input, trace.combo.output) for trace in collection.traces],
|
||||
expected_combos,
|
||||
)
|
||||
|
||||
def test_each_switch_step_stamps_its_traces_with_its_own_capture_window(self) -> None:
|
||||
# The whole point of per-trace timing: three switch steps are measured one
|
||||
# after another, so their traces must NOT all share the collection window.
|
||||
service, _inner, _input_switch = _switched_service(input_steps=3)
|
||||
|
||||
collection = service.acquire_collection(collection_id=7)
|
||||
|
||||
windows_by_step: dict[int, set[tuple[int, int]]] = {}
|
||||
for trace in collection.traces:
|
||||
step = int(trace.combo.input) // _INNER_INPUTS
|
||||
windows_by_step.setdefault(step, set()).add(
|
||||
(int(trace.capture_start_ns), int(trace.capture_end_ns))
|
||||
)
|
||||
|
||||
self.assertEqual(sorted(windows_by_step), [0, 1, 2])
|
||||
for step, windows in windows_by_step.items():
|
||||
self.assertEqual(len(windows), 1, f"step {step} traces disagree on their window")
|
||||
start_ns, end_ns = next(iter(windows))
|
||||
self.assertGreater(start_ns, 0)
|
||||
self.assertGreaterEqual(end_ns, start_ns)
|
||||
# Each step's window sits inside the collection's.
|
||||
self.assertGreaterEqual(start_ns, collection.capture_start_ns)
|
||||
self.assertLessEqual(end_ns, collection.capture_end_ns)
|
||||
|
||||
# Steps are strictly ordered in time — the whole reason the collection-level
|
||||
# window cannot stand in for a per-combo timestamp.
|
||||
step_starts = [next(iter(windows_by_step[step]))[0] for step in sorted(windows_by_step)]
|
||||
self.assertEqual(step_starts, sorted(step_starts))
|
||||
self.assertGreater(len(set(step_starts)), 1)
|
||||
|
||||
|
||||
class ManualComboCaptureUsesTargetedAcquisitionTest(unittest.TestCase):
|
||||
"""The per-combo capture session must not sweep the full widened matrix."""
|
||||
|
||||
@staticmethod
|
||||
def _switched_mock_config() -> RunConfigModel:
|
||||
config = RunConfigModel()
|
||||
config.radar.model = RunConfigModel.LIBREVNA_MULTI_MODEL
|
||||
config.radar.driver_mode = "mock"
|
||||
config.radar.multi_device.slave_serials = ["SLAVE_A", "SLAVE_B"]
|
||||
config.radar.multi_device.input_switch_positions = 3
|
||||
config.apply_device_model_constraints()
|
||||
return config
|
||||
|
||||
def test_manual_capture_runs_one_inner_collection_per_median_sweep(self) -> None:
|
||||
config = self._switched_mock_config()
|
||||
session = SequentialCaptureSession(
|
||||
config=config,
|
||||
kind="s21_calibration",
|
||||
set_name="targeted_test",
|
||||
median_sweep_count=2,
|
||||
)
|
||||
with mock.patch.object(
|
||||
MultiDeviceLibreVnaService,
|
||||
"acquire_collection",
|
||||
autospec=True,
|
||||
side_effect=MultiDeviceLibreVnaService.acquire_collection,
|
||||
) as inner_acquire:
|
||||
session.open()
|
||||
try:
|
||||
trace = session.capture_current_combo()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
first_combo = config.combos[0]
|
||||
self.assertEqual(
|
||||
(trace.combo.input, trace.combo.output),
|
||||
(first_combo.input, first_combo.output),
|
||||
)
|
||||
# 2 median sweeps of ONE physical step — not 2 x 3 full-matrix steps.
|
||||
self.assertEqual(inner_acquire.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,15 @@
|
||||
"""Neutral preprocessing-set helpers for Kamil ADC acquisition."""
|
||||
"""Neutral preprocessing-set helpers — the "run without calibration" path.
|
||||
|
||||
A neutral pair is a calibration set carrying unit S21 (1+0j) and a reference set
|
||||
carrying zero S21. The C++ through-calibrator divides measured/calibration and the
|
||||
reference is subtracted, so applying both leaves the measured S21 untouched. That
|
||||
lets an operator start the pipeline before any real calibration exists, which the
|
||||
required-asset check in `_start_run` would otherwise refuse.
|
||||
|
||||
Supported models: Kamil ADC (axis from the ADC processing grid) and every
|
||||
VNA-style model, including synchronized multi-device LibreVNA (axis from the
|
||||
configured linear sweep grid).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,31 +28,65 @@ from python_app.models.run_config_model import ComboModel, RunConfigModel
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_kamil_adc_neutral_s21_sets(
|
||||
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
|
||||
"""Return whether neutral S21 sets can be generated for this radar model.
|
||||
|
||||
Enabled for the Kamil ADC and for synchronized multi-device LibreVNA, the two
|
||||
models whose emitted frequency axis is fully derivable from the config alone.
|
||||
Other models still work through `build_neutral_s21_sets`, but are kept out of the
|
||||
UI shortcut until their axis has been verified against real hardware.
|
||||
"""
|
||||
return bool(config.is_kamil_adc or config.is_multi_device)
|
||||
|
||||
|
||||
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
|
||||
"""Return the exact per-trace frequency axis the configured radar emits.
|
||||
|
||||
Neutral sets must line up sample-for-sample with live sweeps, so the axis comes
|
||||
from the same source the acquisition path uses: the ADC processing grid for Kamil
|
||||
ADC, and the configured linear sweep grid for every VNA-style model (LibreVNA
|
||||
single and multi-device, SN9000, Compact-M). The C++ preprocessor re-checks this
|
||||
axis against the measured one within a tolerance, so a mismatch fails loudly
|
||||
instead of silently corrupting the correction.
|
||||
"""
|
||||
if config.is_kamil_adc:
|
||||
# Single source of truth for the axis: the same grid the processor emits.
|
||||
processor = KamilAdcSweepProcessor(
|
||||
KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)
|
||||
)
|
||||
return processor.grid_hz
|
||||
|
||||
points = int(config.radar.sweep.points)
|
||||
if points < 1:
|
||||
raise ValueError("Neutral sets require radar.sweep.points >= 1")
|
||||
if points == 1:
|
||||
return np.array([float(config.radar.sweep.start_hz)], dtype=np.float32)
|
||||
# Mirrors both acquisition paths: the native collector seeds this same linspace
|
||||
# and the mock backend generates it outright.
|
||||
return np.linspace(
|
||||
float(config.radar.sweep.start_hz),
|
||||
float(config.radar.sweep.stop_hz),
|
||||
points,
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
|
||||
def build_neutral_s21_sets(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[SweepCollection, SweepCollection]:
|
||||
"""Build neutral S21 calibration/reference collections for the Kamil ADC radar.
|
||||
"""Build neutral S21 calibration/reference collections for the active radar.
|
||||
|
||||
The calibration uses unit S21 (1+0j) and the reference uses zero S21 across
|
||||
every configured combo, so applying them in the preprocessing pipeline leaves
|
||||
the input S21 unchanged. The frequency axis is the exact acquisition grid
|
||||
(``radar.kamil_adc.band``), so neutral sets line up sample-for-sample with
|
||||
live sweeps. Returns the ``(calibration, reference)`` collections.
|
||||
Covers every combo in the effective matrix, so a matrix radar widened by real
|
||||
switches gets a neutral pair for all of its positions and the preprocessor's
|
||||
``validate_combos()`` is satisfied. Returns ``(calibration, reference)``.
|
||||
"""
|
||||
if not config.is_kamil_adc:
|
||||
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
|
||||
|
||||
combos = list(config.combos)
|
||||
if not combos:
|
||||
combos = RunConfigModel.build_full_combos(
|
||||
config.input_switch.positions, config.output_switch.positions
|
||||
)
|
||||
combos = config.build_runtime_combos()
|
||||
if not combos:
|
||||
raise ValueError("Kamil ADC neutral sets require at least one switch combo")
|
||||
raise ValueError("Neutral sets require at least one switch combo")
|
||||
|
||||
# Single source of truth for the axis: the same grid the processor emits.
|
||||
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
|
||||
frequency_hz = processor.grid_hz
|
||||
frequency_hz = neutral_frequency_grid_hz(config)
|
||||
|
||||
now_ns = time.monotonic_ns()
|
||||
calibration = _neutral_collection(
|
||||
@@ -57,11 +102,27 @@ def build_kamil_adc_neutral_s21_sets(
|
||||
monotonic_ns=now_ns,
|
||||
)
|
||||
logger.info(
|
||||
"Built neutral Kamil ADC S21 sets: combos=%d points=%d", len(combos), int(frequency_hz.size)
|
||||
"Built neutral S21 sets: model=%s combos=%d points=%d",
|
||||
config.radar.model,
|
||||
len(combos),
|
||||
int(frequency_hz.size),
|
||||
)
|
||||
return calibration, reference
|
||||
|
||||
|
||||
def build_kamil_adc_neutral_s21_sets(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[SweepCollection, SweepCollection]:
|
||||
"""Build neutral S21 sets, rejecting anything but the Kamil ADC radar.
|
||||
|
||||
Kept as the model-checked entry point for the ADC path; new callers that must
|
||||
work for several radar models should use `build_neutral_s21_sets` instead.
|
||||
"""
|
||||
if not config.is_kamil_adc:
|
||||
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
|
||||
return build_neutral_s21_sets(config)
|
||||
|
||||
|
||||
def _neutral_collection(
|
||||
*,
|
||||
combos: list[ComboModel],
|
||||
|
||||
@@ -19,6 +19,7 @@ from python_app.workflows.radar_config_variants import RadarConfigVariant
|
||||
from python_app.workflows.sequential_capture_workflow import (
|
||||
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
|
||||
SequentialCaptureState,
|
||||
acquire_matrix_combo_collection,
|
||||
combine_collections_via_median,
|
||||
combine_traces_via_median,
|
||||
select_trace_for_combo,
|
||||
@@ -81,14 +82,7 @@ class MultiRadarSequentialCaptureSession:
|
||||
self._manual_matrix_radar_capture = (
|
||||
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
|
||||
)
|
||||
self._combos = (
|
||||
RunConfigModel.build_matrix_radar_virtual_combos()
|
||||
if self._is_matrix_radar
|
||||
else RunConfigModel.build_full_combos(
|
||||
base_config.input_switch.positions,
|
||||
base_config.output_switch.positions,
|
||||
)
|
||||
)
|
||||
self._combos = base_config.build_runtime_combos()
|
||||
if not self._combos:
|
||||
raise RuntimeError("No switch combinations available for capture")
|
||||
|
||||
@@ -199,6 +193,19 @@ class MultiRadarSequentialCaptureSession:
|
||||
self._radar.configure(variant.config.radar.sweep)
|
||||
if self._base_config.runtime.settling_ms > 0:
|
||||
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
||||
if self._manual_matrix_radar_capture:
|
||||
# Only this combo is kept, so acquire the smallest collection
|
||||
# that contains it instead of the full (switch-widened) matrix.
|
||||
per_sweep_traces = [
|
||||
select_trace_for_combo(
|
||||
acquire_matrix_combo_collection(self._radar, combo), combo
|
||||
)
|
||||
for _ in range(self._median_sweep_count)
|
||||
]
|
||||
trace = combine_traces_via_median(per_sweep_traces)
|
||||
pending_traces_by_radar_key[variant.radar_key] = [trace]
|
||||
display_traces.append(trace)
|
||||
else:
|
||||
collections: list[SweepCollection] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
collection = self._radar.acquire_collection(collection_id=1)
|
||||
@@ -207,12 +214,6 @@ class MultiRadarSequentialCaptureSession:
|
||||
f"Matrix radar variant {variant.display_name} returned no traces"
|
||||
)
|
||||
collections.append(collection)
|
||||
if self._manual_matrix_radar_capture:
|
||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
||||
trace = combine_traces_via_median(per_sweep_traces)
|
||||
pending_traces_by_radar_key[variant.radar_key] = [trace]
|
||||
display_traces.append(trace)
|
||||
else:
|
||||
combined_collection = combine_collections_via_median(collections)
|
||||
pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces)
|
||||
display_traces.append(combined_collection.traces[-1])
|
||||
@@ -231,6 +232,7 @@ class MultiRadarSequentialCaptureSession:
|
||||
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
||||
sweep_traces: list[TraceData] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = self._radar.acquire()
|
||||
sweep_traces.append(
|
||||
TraceData(
|
||||
@@ -238,6 +240,8 @@ class MultiRadarSequentialCaptureSession:
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
trace = combine_traces_via_median(sweep_traces)
|
||||
|
||||
@@ -64,11 +64,7 @@ class SequentialCaptureSession:
|
||||
self._manual_matrix_radar_capture = (
|
||||
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
|
||||
)
|
||||
self._combos = (
|
||||
RunConfigModel.build_matrix_radar_virtual_combos()
|
||||
if self._is_matrix_radar
|
||||
else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
)
|
||||
self._combos = config.build_runtime_combos()
|
||||
if not self._combos:
|
||||
raise RuntimeError("No switch combinations available for capture")
|
||||
|
||||
@@ -160,20 +156,27 @@ class SequentialCaptureSession:
|
||||
raise RuntimeError("Capture session is already complete")
|
||||
|
||||
if self._is_matrix_radar:
|
||||
collections: list[SweepCollection] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
collection = self._radar.acquire_collection(collection_id=1)
|
||||
if not collection.traces:
|
||||
raise RuntimeError("Matrix radar capture returned no traces")
|
||||
collections.append(collection)
|
||||
if self._manual_matrix_radar_capture:
|
||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
||||
# Only this combo is kept, so acquire the smallest collection that
|
||||
# contains it instead of the full (switch-widened) matrix.
|
||||
per_sweep_traces = [
|
||||
select_trace_for_combo(
|
||||
acquire_matrix_combo_collection(self._radar, combo), combo
|
||||
)
|
||||
for _ in range(self._median_sweep_count)
|
||||
]
|
||||
trace = combine_traces_via_median(per_sweep_traces)
|
||||
self._traces.append(trace)
|
||||
self._next_index += 1
|
||||
logger.debug("Captured matrix combo input=%d output=%d", combo.input, combo.output)
|
||||
return trace
|
||||
|
||||
collections: list[SweepCollection] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
collection = self._radar.acquire_collection(collection_id=1)
|
||||
if not collection.traces:
|
||||
raise RuntimeError("Matrix radar capture returned no traces")
|
||||
collections.append(collection)
|
||||
combined_collection = combine_collections_via_median(collections)
|
||||
self._traces.extend(combined_collection.traces)
|
||||
self._next_index = len(self._combos)
|
||||
@@ -199,6 +202,7 @@ class SequentialCaptureSession:
|
||||
|
||||
sweep_traces: list[TraceData] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = self._radar.acquire()
|
||||
sweep_traces.append(
|
||||
TraceData(
|
||||
@@ -206,6 +210,8 @@ class SequentialCaptureSession:
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
trace = combine_traces_via_median(sweep_traces)
|
||||
@@ -305,6 +311,32 @@ class SequentialCaptureSession:
|
||||
return self._combos[self._next_index]
|
||||
|
||||
|
||||
def acquire_matrix_combo_collection(
|
||||
radar: MatrixRadarService,
|
||||
combo: ComboModel,
|
||||
collection_id: int = 1,
|
||||
) -> SweepCollection:
|
||||
"""Acquire the smallest matrix collection that contains one combo.
|
||||
|
||||
A switch-widened matrix radar (``SwitchedMatrixRadarService``) can acquire just
|
||||
the physical switch step carrying the combo, which is several times faster than
|
||||
the full matrix and keeps the per-combo capture UI responsive. Plain matrix
|
||||
radars expose only full-matrix acquisition, so they fall back to it.
|
||||
"""
|
||||
acquire_combo = getattr(radar, "acquire_combo_collection", None)
|
||||
if callable(acquire_combo):
|
||||
collection = acquire_combo(
|
||||
input_pos=int(combo.input),
|
||||
output_pos=int(combo.output),
|
||||
collection_id=collection_id,
|
||||
)
|
||||
else:
|
||||
collection = radar.acquire_collection(collection_id=collection_id)
|
||||
if not collection.traces:
|
||||
raise RuntimeError("Matrix radar capture returned no traces")
|
||||
return collection
|
||||
|
||||
|
||||
def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> TraceData:
|
||||
"""Return the trace matching a virtual combo from a full multi-device capture."""
|
||||
for trace in collection.traces:
|
||||
@@ -356,11 +388,16 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
|
||||
s21_median = (
|
||||
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
|
||||
).astype(np.complex64)
|
||||
# The median is built from every input sweep, so its window spans all of them.
|
||||
capture_starts = [int(t.capture_start_ns) for t in traces if int(t.capture_start_ns) > 0]
|
||||
capture_ends = [int(t.capture_end_ns) for t in traces if int(t.capture_end_ns) > 0]
|
||||
return TraceData(
|
||||
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
|
||||
frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32),
|
||||
s11=s11_median,
|
||||
s21=s21_median,
|
||||
capture_start_ns=min(capture_starts) if capture_starts else 0,
|
||||
capture_end_ns=max(capture_ends) if capture_ends else 0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+143
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "librevna",
|
||||
"model": "librevna_multi",
|
||||
"serial": "",
|
||||
"remote_host": "127.0.0.1",
|
||||
"remote_port": 50209,
|
||||
@@ -8,9 +8,14 @@
|
||||
"mock_signal_hz": 5000000.0,
|
||||
"visa_library": "",
|
||||
"multi_device": {
|
||||
"slave_serials": [],
|
||||
"slave_serials": [
|
||||
"20A1307D5532",
|
||||
"2072306C5532"
|
||||
],
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
@@ -20,7 +25,18 @@
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
"stop_timeout_s": 2.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 0.0,
|
||||
"freq0_hz": 2046000000.0,
|
||||
"phase1_rad": 300.0,
|
||||
"freq1_hz": 5612000000.0
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2100000000.0,
|
||||
"stop_hz": 5500000000.0,
|
||||
"points": 2048
|
||||
}
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
@@ -75,7 +91,7 @@
|
||||
"driver_mode": "mock",
|
||||
"driver": "h7992",
|
||||
"radar_port": 2,
|
||||
"positions": 4,
|
||||
"positions": 12,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 22,
|
||||
@@ -92,11 +108,14 @@
|
||||
"debounce_ms": 50,
|
||||
"action": "capture_tmp_reference"
|
||||
},
|
||||
"logging": {
|
||||
"level": "debug"
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": 0,
|
||||
"idle_sleep_ms": 2,
|
||||
"continuous": true,
|
||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||
"processing_live_config_path": "/home/guriy/Documents/radar_system/python_app/runtime/processing_live.json",
|
||||
"locator_server": {
|
||||
"device_id": 3,
|
||||
"protocol_version": 1,
|
||||
@@ -123,6 +142,38 @@
|
||||
"input": 3,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 4,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 5,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 6,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 7,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 8,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 9,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 10,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 11,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 1
|
||||
@@ -138,17 +189,49 @@
|
||||
{
|
||||
"input": 3,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 4,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 5,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 6,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 7,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 8,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 9,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 10,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 11,
|
||||
"output": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"preprocess": {
|
||||
"s21": {
|
||||
"calibration": {
|
||||
"set_name": "smoke_cal",
|
||||
"set_name": "smoke_cal3",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "smoke_ref",
|
||||
"set_name": "smoke_cal3",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
@@ -219,6 +302,54 @@
|
||||
"x_m": 0.185,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 4,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 5,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 6,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 7,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 8,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 9,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 10,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 11,
|
||||
"x_m": 0.0,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -253,7 +384,7 @@
|
||||
"version": 1,
|
||||
"switches": {
|
||||
"combo_mode": "text",
|
||||
"combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1",
|
||||
"combos_text": "0:0,1:0,2:0,3:0,4:0,5:0,6:0,7:0,8:0,9:0,10:0,11:0,0:1,1:1,2:1,3:1,4:1,5:1,6:1,7:1,8:1,9:1,10:1,11:1",
|
||||
"single_input": "0",
|
||||
"single_output": "0"
|
||||
},
|
||||
@@ -262,6 +393,7 @@
|
||||
"pass_through": {
|
||||
"show_magnitude": true,
|
||||
"show_phase": false,
|
||||
"unwrap_phase": false,
|
||||
"combo_filter": "",
|
||||
"fixed_y_enabled": false,
|
||||
"y_min_db": -100.0,
|
||||
@@ -333,7 +465,8 @@
|
||||
"data_actions": {
|
||||
"save_count": 10,
|
||||
"save_path": "python_app/data/snapshots",
|
||||
"save_name": "snapshot_simulator"
|
||||
"save_name": "snapshot_simulator",
|
||||
"record_count": 100
|
||||
},
|
||||
"preprocess_dialog": {
|
||||
"set_name": "smoke_cal",
|
||||
|
||||
Reference in New Issue
Block a user