Compare commits

...
Author SHA1 Message Date
Ayzen 7997abe2d9 added capture time for every sweep 2026-09-03 17:30:19 +03:00
Guriy 8a52431bd3 cart_firmware_settings_changed 2026-08-27 13:53:33 +03:00
Ayzen cc6d189d52 cart_firmware_added 2026-08-26 15:40:10 +03:00
Ayzen 6ada811c2f some fixes 2026-08-26 15:02:21 +03:00
BogatskiyG 74723bb635 Merge branch 'laser-temp-monitoring' into feature/switched-matrix-radar 2026-08-04 14:57:03 +03:00
BogatskiyG 7c0ae1ecf8 removed the b-scan collection_id floor 2026-08-03 18:59:34 +03:00
BogatskiyG b183401e6f added bscan reprocessing 2026-07-31 18:14:54 +03:00
BogatskiyG d61b59b9a4 added a bscan extension 2026-07-31 15:46:08 +03:00
BogatskiyG 7c6cab07fc some changes and log fix 2026-07-30 19:58:53 +03:00
BogatskiyG 68bec25f17 Add real switch support for multi-device matrix radar
MultiDeviceLibreVnaService only exposes the 2x4 virtual combo matrix on
its own USB transport. When a physical GPIO switch sits on the master
stimulus and/or slave receiver path, the effective matrix is wider than
that. SwitchedMatrixRadarService wraps the inner service and drives the
extra switch(es) between acquire_collection calls, widening the combo
matrix by the physical position counts (matrix_output_switch_positions /
matrix_input_switch_positions in RunConfigModel).

Combo-matrix construction was centralized into
RunConfigModel.build_runtime_combos() so the GUI, workflows, and codec
all derive the same widened matrix instead of each computing its own
version of the virtual 2x4 layout.
2026-07-29 17:44:28 +03:00
64 changed files with 2984 additions and 256 deletions
+15 -3
View File
@@ -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));
}
+5
View File
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
+10
View File
@@ -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
+166
View File
@@ -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()
+126 -48
View File
@@ -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:
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)
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,13 +561,40 @@ class AppWindowPreprocessMixin:
f"{display_name} batch capture started: remaining="
f"{session.state().total_count - session.state().captured_count}"
)
while not session.is_complete():
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)
try:
while not session.is_complete():
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 _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)
+26 -18
View File
@@ -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
+16 -2
View File
@@ -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)
)
+11 -1
View File
@@ -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)
+7
View File
@@ -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,
+4
View File
@@ -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)
+12
View File
@@ -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,
+27 -4
View File
@@ -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:
+20 -3
View File
@@ -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:
+17 -2
View File
@@ -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 "
+13 -1
View File
@@ -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)
+15
View File
@@ -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",
+4
View File
@@ -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)),
)
)
+14 -1
View File
@@ -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()
+282
View File
@@ -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()
+48
View File
@@ -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()
+12 -8
View File
@@ -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])
# --------------------------------------------------------------------------- #
+42 -3
View File
@@ -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,20 +193,27 @@ 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)
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError(
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]
# 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)
if not collection.traces:
raise RuntimeError(
f"Matrix radar variant {variant.display_name} returned no traces"
)
collections.append(collection)
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,
)
+144 -11
View File
@@ -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",
@@ -342,4 +475,4 @@
"median_sweep_count": 5
}
}
}
}