added k209 driver

This commit is contained in:
Ayzen
2026-04-29 15:20:56 +03:00
parent 1ea2aabf87
commit e05c06bcbe
10 changed files with 1327 additions and 1 deletions
+3 -1
View File
@@ -2,6 +2,8 @@ CXX := g++
CXXFLAGS := -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread -MMD -MP CXXFLAGS := -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread -MMD -MP
LDFLAGS := -pthread -lrt LDFLAGS := -pthread -lrt
ORCH_LDFLAGS := $(LDFLAGS) -lusb-1.0 ORCH_LDFLAGS := $(LDFLAGS) -lusb-1.0
VISA_CXXFLAGS ?= -I/usr/include/ni-visa
VISA_LDFLAGS := $(LDFLAGS) -lvisa
BUILD_DIR := build BUILD_DIR := build
BIN_DIR := $(BUILD_DIR)/bin BIN_DIR := $(BUILD_DIR)/bin
@@ -79,7 +81,7 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS)
$(BUILD_DIR)/%.o: %.cpp $(BUILD_DIR)/%.o: %.cpp
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@ $(CXX) $(CXXFLAGS) $(VISA_CXXFLAGS) $(INCLUDES) -c $< -o $@
-include $(DEPFILES) -include $(DEPFILES)
Binary file not shown.
@@ -0,0 +1,414 @@
#include "../compact_m_k209_driver.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstring>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <utility>
namespace radar::drivers {
namespace {
constexpr std::uint32_t kFloatBytes = 4U;
constexpr std::uint32_t kComplexScalarCount = 2U;
constexpr std::uint64_t kMaxBinaryBlockBytes = 512ULL * 1024ULL * 1024ULL;
[[nodiscard]] auto visa_resource(const std::string& resource) -> ViRsrc {
return reinterpret_cast<ViRsrc>(const_cast<char*>(resource.c_str()));
}
[[nodiscard]] auto visa_buffer(std::uint8_t* data) -> ViBuf {
return reinterpret_cast<ViBuf>(data);
}
[[nodiscard]] auto visa_const_buffer(const char* data) -> ViBuf {
return reinterpret_cast<ViBuf>(const_cast<char*>(data));
}
[[nodiscard]] auto is_line_ending(std::uint8_t value) -> bool {
return value == static_cast<std::uint8_t>('\n') || value == static_cast<std::uint8_t>('\r');
}
[[nodiscard]] auto is_response_separator(std::uint8_t value) -> bool {
return is_line_ending(value) || value == static_cast<std::uint8_t>(';');
}
[[nodiscard]] auto checked_vi_count(std::uint64_t value) -> ViUInt32 {
const auto max_count = static_cast<std::uint64_t>(std::numeric_limits<ViUInt32>::max());
return static_cast<ViUInt32>(std::min(value, max_count));
}
} // namespace
CompactMK209Driver::CompactMK209Driver(CompactMK209DriverSettings settings) : settings_(std::move(settings)) {}
CompactMK209Driver::~CompactMK209Driver() {
try {
close();
} catch (...) {
}
}
void CompactMK209Driver::open() {
if (is_open_) {
return;
}
if (settings_.resource.empty()) {
throw std::runtime_error("K209 VISA resource must not be empty");
}
if (settings_.sweep.points < 2U) {
throw std::runtime_error("K209 sweep points must be >= 2");
}
if (settings_.sweep.stop_hz < settings_.sweep.start_hz) {
throw std::runtime_error("K209 sweep stop_hz must be >= start_hz");
}
if (!(settings_.sweep.if_bandwidth_hz > 0.0F)) {
throw std::runtime_error("K209 IF bandwidth must be > 0");
}
ViStatus status = viOpenDefaultRM(&resource_manager_);
if (status < VI_SUCCESS) {
throw std::runtime_error("Failed to open VISA resource manager: status=" + std::to_string(status));
}
try {
check_status(
viOpen(resource_manager_, visa_resource(settings_.resource), VI_NULL, VI_NULL, &instrument_),
"open K209 VISA resource"
);
check_status(
viSetAttribute(instrument_, VI_ATTR_TMO_VALUE, static_cast<ViAttrState>(settings_.timeout_ms)),
"set K209 VISA timeout"
);
check_status(
viSetAttribute(instrument_, VI_ATTR_TERMCHAR_EN, static_cast<ViAttrState>(VI_FALSE)),
"disable K209 VISA termchar"
);
write_command("*CLS");
configure_device();
} catch (...) {
close();
throw;
}
is_open_ = true;
}
void CompactMK209Driver::close() {
if (instrument_ != VI_NULL) {
viClose(instrument_);
instrument_ = VI_NULL;
}
if (resource_manager_ != VI_NULL) {
viClose(resource_manager_);
resource_manager_ = VI_NULL;
}
is_open_ = false;
}
auto CompactMK209Driver::acquire_sweep() -> SweepTrace {
require_open();
auto traces = query_sweep_trace_pair(settings_.sweep.points);
SweepTrace trace{};
trace.frequency_hz = frequency_hz_;
trace.s11 = complex_trace_from_interleaved(traces.first);
trace.s21 = complex_trace_from_interleaved(traces.second);
return trace;
}
auto CompactMK209Driver::query_identity() -> std::string {
require_open();
return trim_ascii(query_string("*IDN?"));
}
auto CompactMK209Driver::query_system_error() -> std::string {
require_open();
return trim_ascii(query_string("SYST:ERR?"));
}
void CompactMK209Driver::configure_device() {
if (settings_.preset_on_open) {
write_command("SYST:PRES");
expect_operation_complete("*OPC?", "wait for K209 preset");
}
write_command("SENS:FREQ:STAR " + format_frequency(settings_.sweep.start_hz));
write_command("SENS:FREQ:STOP " + format_frequency(settings_.sweep.stop_hz));
write_command("SENS:SWE:POIN " + std::to_string(settings_.sweep.points));
write_command("SENS:SWE:POIN:TIME 0");
write_command("SENS:BAND " + format_frequency(settings_.sweep.if_bandwidth_hz));
write_command("SOUR:POW " + format_power(settings_.sweep.power_dbm));
write_command("SENS:AVER OFF");
write_command("CALC:PAR1:DEF S21");
write_command("CALC:PAR1:SEL");
write_command("FORM:DATA REAL32");
write_command("FORM:BORD SWAP");
write_command("INIT:CONT ON");
write_command("TRIG:SOUR BUS");
expect_operation_complete("*OPC?", "wait for K209 setup");
frequency_hz_ = query_float_array("SENS:FREQ:DATA?", settings_.sweep.points);
}
void CompactMK209Driver::expect_operation_complete(const std::string& command, const std::string& context) {
const auto response = trim_ascii(query_string(command));
if (response != "1") {
throw std::runtime_error(context + " returned unexpected *OPC? response: " + response);
}
}
void CompactMK209Driver::write_command(const std::string& command) {
require_open();
const std::string message = command + "\n";
ViUInt32 written = 0;
check_status(
viWrite(instrument_, visa_const_buffer(message.data()), static_cast<ViUInt32>(message.size()), &written),
"write K209 command: " + command
);
if (written != message.size()) {
throw std::runtime_error("Incomplete K209 command write: " + command);
}
}
auto CompactMK209Driver::query_string(const std::string& command) -> std::string {
write_command(command);
return read_line();
}
auto CompactMK209Driver::query_float_array(
const std::string& command,
std::uint32_t expected_values
) -> std::vector<float> {
write_command(command);
return read_float_array_response(command, expected_values);
}
auto CompactMK209Driver::query_sweep_trace_pair(std::uint32_t points)
-> std::pair<std::vector<float>, std::vector<float>> {
write_command("TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21");
const auto opc_response = read_ascii_token();
if (opc_response != "1") {
throw std::runtime_error("K209 sweep returned unexpected *OPC? response: " + opc_response);
}
return {
read_float_array_response("SENS:DATA:CORR? S11", points * kComplexScalarCount),
read_float_array_response("SENS:DATA:CORR? S21", points * kComplexScalarCount),
};
}
auto CompactMK209Driver::read_float_array_response(
const std::string& context,
std::uint32_t expected_values
) -> std::vector<float> {
auto values = float_array_from_little_endian(read_ieee_block());
if (values.size() != expected_values) {
throw std::runtime_error(
"K209 response for " + context + " returned " + std::to_string(values.size()) +
" float32 values, expected " + std::to_string(expected_values)
);
}
return values;
}
auto CompactMK209Driver::read_ascii_token() -> std::string {
require_open();
std::string token{};
token.reserve(32);
while (true) {
const auto byte = read_byte();
if (is_response_separator(byte)) {
if (!token.empty()) {
return token;
}
continue;
}
token.push_back(static_cast<char>(byte));
if (token.size() > 64U * 1024U) {
throw std::runtime_error("K209 ASCII response token is too long");
}
}
}
auto CompactMK209Driver::read_line() -> std::string {
require_open();
std::string line{};
line.reserve(128);
bool has_content = false;
while (true) {
const auto byte = read_byte();
if (!has_content && is_line_ending(byte)) {
continue;
}
if (byte == static_cast<std::uint8_t>('\n')) {
break;
}
if (byte != static_cast<std::uint8_t>('\r')) {
line.push_back(static_cast<char>(byte));
has_content = true;
}
if (line.size() > 64U * 1024U) {
throw std::runtime_error("K209 ASCII response line is too long");
}
}
return line;
}
auto CompactMK209Driver::read_byte() -> std::uint8_t {
auto bytes = read_exact(1);
return bytes[0];
}
auto CompactMK209Driver::read_exact(std::uint64_t size) -> std::vector<std::uint8_t> {
require_open();
std::vector<std::uint8_t> bytes(size);
std::uint64_t offset = 0;
while (offset < size) {
ViUInt32 transferred = 0;
const auto count = checked_vi_count(size - offset);
check_status(
viRead(instrument_, visa_buffer(bytes.data() + offset), count, &transferred),
"read K209 response bytes"
);
if (transferred == 0U) {
throw std::runtime_error("K209 VISA read returned zero bytes before expected payload was complete");
}
offset += transferred;
}
return bytes;
}
auto CompactMK209Driver::read_ieee_block() -> std::vector<std::uint8_t> {
std::uint8_t marker = read_byte();
while (is_response_separator(marker)) {
marker = read_byte();
}
if (marker != static_cast<std::uint8_t>('#')) {
throw std::runtime_error("K209 binary response does not start with IEEE block marker '#'");
}
const auto digit = read_byte();
if (digit != static_cast<std::uint8_t>('8')) {
throw std::runtime_error("K209 binary response uses unsupported IEEE block header width");
}
const auto size_text_bytes = read_exact(8);
std::uint64_t payload_size = 0;
for (const auto byte : size_text_bytes) {
if (!std::isdigit(static_cast<unsigned char>(byte))) {
throw std::runtime_error("K209 binary response has non-numeric payload size");
}
payload_size = (payload_size * 10ULL) + static_cast<std::uint64_t>(byte - static_cast<std::uint8_t>('0'));
}
if (payload_size == 0U || payload_size > kMaxBinaryBlockBytes) {
throw std::runtime_error("K209 binary response payload size is out of allowed range");
}
if ((payload_size % kFloatBytes) != 0U) {
throw std::runtime_error("K209 binary response payload size is not aligned to float32 values");
}
return read_exact(payload_size);
}
void CompactMK209Driver::check_status(ViStatus status, const std::string& context) const {
if (status >= VI_SUCCESS) {
return;
}
throw std::runtime_error(context + ": " + status_message(status));
}
auto CompactMK209Driver::status_message(ViStatus status) const -> std::string {
std::array<ViChar, 256> description{};
const auto session = instrument_ != VI_NULL ? instrument_ : resource_manager_;
if (session != VI_NULL && viStatusDesc(session, status, description.data()) >= VI_SUCCESS) {
return std::string(description.data()) + " (status=" + std::to_string(status) + ")";
}
return "VISA status=" + std::to_string(status);
}
void CompactMK209Driver::require_open() const {
if (instrument_ == VI_NULL) {
throw std::runtime_error("K209 VISA instrument is not open");
}
}
auto CompactMK209Driver::float_array_from_little_endian(std::vector<std::uint8_t> bytes) -> std::vector<float> {
if ((bytes.size() % kFloatBytes) != 0U) {
throw std::runtime_error("K209 binary float payload is not aligned to 4 bytes");
}
std::vector<float> values(bytes.size() / kFloatBytes, 0.0F);
for (std::size_t index = 0; index < values.size(); ++index) {
const auto offset = index * kFloatBytes;
const std::uint32_t bits = static_cast<std::uint32_t>(bytes[offset]) |
(static_cast<std::uint32_t>(bytes[offset + 1U]) << 8U) |
(static_cast<std::uint32_t>(bytes[offset + 2U]) << 16U) |
(static_cast<std::uint32_t>(bytes[offset + 3U]) << 24U);
std::memcpy(&values[index], &bits, sizeof(float));
}
return values;
}
auto CompactMK209Driver::complex_trace_from_interleaved(const std::vector<float>& values)
-> std::vector<ipc::Complex32> {
if ((values.size() % kComplexScalarCount) != 0U) {
throw std::runtime_error("K209 complex trace payload has odd scalar count");
}
std::vector<ipc::Complex32> trace{};
trace.reserve(values.size() / kComplexScalarCount);
for (std::size_t index = 0; index < values.size(); index += kComplexScalarCount) {
trace.push_back(ipc::Complex32{.re = values[index], .im = values[index + 1U]});
}
return trace;
}
auto CompactMK209Driver::format_frequency(float value_hz) -> std::string {
if (!std::isfinite(value_hz)) {
throw std::runtime_error("K209 frequency value is not finite");
}
std::ostringstream stream;
stream.precision(9);
stream << std::fixed << value_hz;
return stream.str();
}
auto CompactMK209Driver::format_power(float value_dbm) -> std::string {
if (!std::isfinite(value_dbm)) {
throw std::runtime_error("K209 power value is not finite");
}
std::ostringstream stream;
stream.precision(3);
stream << std::fixed << value_dbm;
return stream.str();
}
auto CompactMK209Driver::trim_ascii(std::string value) -> std::string {
const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
});
const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}).base();
if (first >= last) {
return {};
}
return std::string(first, last);
}
} // namespace radar::drivers
@@ -0,0 +1,71 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include <visa.h>
#include "radar_driver.hpp"
#include "run_config.hpp"
namespace radar::drivers {
struct CompactMK209DriverSettings {
std::string resource{};
config::RadarSweepSettings sweep{};
std::uint32_t timeout_ms = 20'000;
bool preset_on_open = true;
};
class CompactMK209Driver final : public RadarDriver {
public:
explicit CompactMK209Driver(CompactMK209DriverSettings settings);
~CompactMK209Driver() override;
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
[[nodiscard]] auto query_identity() -> std::string;
[[nodiscard]] auto query_system_error() -> std::string;
private:
void configure_device();
void expect_operation_complete(const std::string& command, const std::string& context);
void write_command(const std::string& command);
[[nodiscard]] auto query_string(const std::string& command) -> std::string;
[[nodiscard]] auto query_float_array(const std::string& command, std::uint32_t expected_values)
-> std::vector<float>;
[[nodiscard]] auto query_sweep_trace_pair(std::uint32_t points)
-> std::pair<std::vector<float>, std::vector<float>>;
[[nodiscard]] auto read_float_array_response(const std::string& context, std::uint32_t expected_values)
-> std::vector<float>;
[[nodiscard]] auto read_ascii_token() -> std::string;
[[nodiscard]] auto read_line() -> std::string;
[[nodiscard]] auto read_byte() -> std::uint8_t;
[[nodiscard]] auto read_exact(std::uint64_t size) -> std::vector<std::uint8_t>;
[[nodiscard]] auto read_ieee_block() -> std::vector<std::uint8_t>;
void check_status(ViStatus status, const std::string& context) const;
[[nodiscard]] auto status_message(ViStatus status) const -> std::string;
void require_open() const;
[[nodiscard]] static auto float_array_from_little_endian(std::vector<std::uint8_t> bytes) -> std::vector<float>;
[[nodiscard]] static auto complex_trace_from_interleaved(const std::vector<float>& values)
-> std::vector<ipc::Complex32>;
[[nodiscard]] static auto format_frequency(float value_hz) -> std::string;
[[nodiscard]] static auto format_power(float value_dbm) -> std::string;
[[nodiscard]] static auto trim_ascii(std::string value) -> std::string;
CompactMK209DriverSettings settings_{};
std::vector<float> frequency_hz_{};
bool is_open_ = false;
ViSession resource_manager_ = VI_NULL;
ViSession instrument_ = VI_NULL;
};
} // namespace radar::drivers
+278
View File
@@ -0,0 +1,278 @@
# Compact-M K209 / S2VNA Setup
This project controls the Compact-M K209 through the S2VNA SCPI server.
The production path is:
```text
K209 --USB-C--> S2VNA --HiSLIP/VISA--> radar_system
```
There is no direct USB driver for K209 in this project. Do not use mock
transports, socket fallbacks, or `pyvisa-py` for the K209 path. The required
transport dependency is an IVI/Vendor VISA implementation that provides both
`visa.h` and `libvisa.so`.
For maximum throughput the driver uses:
- HiSLIP, not TCP Socket.
- A persistent VISA session.
- Binary `FORM:DATA REAL32`.
- Little-endian `FORM:BORD SWAP`.
- One-time sweep configuration outside the acquisition loop.
- Cached frequency axis after configuration, so repeated acquisition reads only
the complex traces.
- Point delay forced to `0` and sweep averaging forced `OFF`.
- The acquisition loop sends one synchronized SCPI message:
`TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21`.
This keeps the trigger state valid while avoiding separate round trips for
`*OPC?`, `S11`, and `S21`.
Do not remove the inline `*OPC?` from the hot path. On the tested K209/S2VNA
setup, `TRIG:SING` followed immediately by data queries can return data but
queues SCPI error `-211,"Trigger system is not in the trigger wait state"`.
## Required Components
Install these on the machine that runs the K209 smoke tests or acquisition
process:
1. S2VNA from Planar.
- On Linux the S2VNA manual describes the AppImage package
`S2VNA_X.X.X_x86_64.AppImage`.
- The K209 is connected to this S2VNA instance over USB-C.
2. IVI VISA runtime and development files.
- Must provide `visa.h`.
- Must provide `libvisa.so`.
- Must support TCPIP HiSLIP resources.
- Suitable implementations include NI-VISA or Keysight IO Libraries Suite.
3. Project Python environment.
- Use the repository virtual environment, not system Python.
- Install `requirements.txt` into `.venv`.
4. Native build dependencies.
- C++20 compiler.
- `make`.
- `libusb-1.0` development files for the existing LibreVNA build.
## Ubuntu x86_64
Install base packages:
```bash
sudo apt update
sudo apt install build-essential make pkg-config python3-venv python3-pip libusb-1.0-0-dev
```
Create or update the project virtual environment:
```bash
cd /home/europa/Documents/radar_system
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements.txt
```
Install an IVI VISA implementation. For NI-VISA, install the NI Linux package
repository from NI, then install the `ni-visa` package with the system package
manager. For Keysight, install Keysight IO Libraries Suite for Linux with VISA
support enabled.
Do not use Ubuntu's `libvisa-dev` / `libvisa0` packages for the K209 production
path. Those packages come from `librevisa` and are not the vendor IVI VISA stack
used for high-speed HiSLIP operation.
After installation, verify that the system exposes the required C and runtime
files:
```bash
ldconfig -p | grep libvisa
find /usr /opt -name visa.h -o -name libvisa.so 2>/dev/null
.venv/bin/pyvisa-info
```
`pyvisa-info` must show the `ivi` backend with a found binary library.
If `visa.h` or `libvisa.so` is installed outside the default compiler/linker
paths, pass the paths explicitly:
```bash
make build/bin/k209_smoke_test \
VISA_CXXFLAGS='-I/path/to/visa/include' \
VISA_LDFLAGS='-pthread -lrt -L/path/to/visa/lib -lvisa'
```
## S2VNA HiSLIP Server
Start S2VNA with the K209 connected over USB-C. Enable HiSLIP server on port
`4880`. This can be done from the S2VNA UI:
```text
System -> Settings -> Remote control network settings -> HiSLIP server -> On
System -> Settings -> Remote control network settings -> HiSLIP port -> 4880
```
The S2VNA command line can also enable the server:
```bash
./S2VNA_X.X.X_x86_64.AppImage /HislipServer:on /HislipPort:4880
```
For unattended runs, S2VNA also supports hiding the UI:
```bash
./S2VNA_X.X.X_x86_64.AppImage /HislipServer:on /HislipPort:4880 /visible:off
```
Verify that the server is listening:
```bash
ss -ltnp | grep 4880
```
The local VISA resource is:
```text
TCPIP0::127.0.0.1::hislip0,4880::INSTR
```
If S2VNA runs on another machine, replace `127.0.0.1` with that machine's IP
address.
## Python Smoke Test
Use the project virtual environment:
```bash
cd /home/europa/Documents/radar_system
.venv/bin/python -m python_app.scripts.k209_smoke_test \
--resource 'TCPIP0::127.0.0.1::hislip0,4880::INSTR' \
--visa-library '@ivi' \
--start-hz 10000000 \
--stop-hz 100000000 \
--points 11 \
--ifbw-hz 10000 \
--power-dbm -20 \
--no-preset
```
Expected result:
```text
K209 IDN: Planar, K209, ...
K209 sweep OK: points=11, first_hz=10000000.000, last_hz=100000000.000, ...
```
Use `--no-preset` for the first smoke test to avoid resetting the current S2VNA
session. Remove it when testing the full driver setup path.
## C++ Smoke Test
Build the C++ K209 smoke binary:
```bash
cd /home/europa/Documents/radar_system
make build/bin/k209_smoke_test
```
Run it against the local S2VNA HiSLIP server:
```bash
build/bin/k209_smoke_test \
--resource 'TCPIP0::127.0.0.1::hislip0,4880::INSTR' \
--start-hz 10000000 \
--stop-hz 100000000 \
--points 11 \
--ifbw-hz 10000 \
--power-dbm -20 \
--no-preset
```
If the build fails with `fatal error: visa.h: No such file or directory`, the
IVI VISA development headers are not installed or are not visible to the
compiler. If linking fails with `cannot find -lvisa`, the IVI VISA runtime or
linker path is not installed correctly.
## Python Sweep Benchmark
Use this script to measure hot-loop sweep throughput for a selected sweep
configuration. It keeps one VISA session open, configures the sweep once, caches
the frequency axis once, then times repeated synchronized trigger/read cycles
for binary `S11` and `S21` arrays.
Edit the configuration constants at the top of
`python_app/scripts/k209_sweep_benchmark.py`, then run:
```bash
cd /home/europa/Documents/radar_system
.venv/bin/python -m python_app.scripts.k209_sweep_benchmark
```
The reported `points_per_s` is:
```text
timed_sweeps * points / total_timed_seconds
```
Set `INCLUDE_RESULT_CONVERSION = True` only when you want to include Python
`SweepResult` construction overhead. Keep it `False` when measuring the
device/transport hot path.
## K209 Limits
The connected K209 reports these limits through SCPI service/capability
queries:
```text
frequency_hz: 9000 .. 9000000000
ifbw_hz: 1 .. 300000
power_dbm: -55 .. +5
points: 2 .. 500001
```
The S2VNA manual specifies the IFBW range with 1/3-decade spacing.
`SENS:BAND` accepts values in the 1, 1.5, 2, 3, 5, 7 sequence across decades
and clamps out-of-range values to the nearest limit.
The manual describes metrology/dynamic-range frequency subranges such as
`9 kHz..300 kHz`, `300 kHz..2 MHz`, and `2 MHz..9 GHz`, but it does not expose a
SCPI command for manually selecting an internal RF band. S2VNA handles internal
range switching. Benchmark the exact frequency window used by the application
when sweep speed matters.
The S2VNA manual also describes a SCPI FIFO buffer mode for very high-rate
external-trigger sequences. It is not the right default for this driver stage:
the manual limits it to specific analyzer families, external/repeated trigger
workflows, one open channel, disabled display updates, and at most 3000 points
per sweep. The current K209 path therefore uses synchronized HiSLIP binary
sweeps instead of FIFO.
## Raspberry Pi OS
Raspberry Pi 5 uses ARM64/AArch64 when running 64-bit Raspberry Pi OS. The S2VNA
Linux package described in the S2VNA manual is `x86_64`, and common vendor VISA
packages are also primarily published for x86_64 Linux.
Do not assume local K209 acquisition on Raspberry Pi works until both of these
are available for ARM64:
1. S2VNA build that can run on Raspberry Pi OS ARM64 and control the K209 over
USB-C.
2. IVI VISA implementation for ARM64 that provides `visa.h`, `libvisa.so`, and
TCPIP HiSLIP support.
If those ARM64 dependencies are not available, run S2VNA and the acquisition
process on an Ubuntu x86_64 machine. Raspberry Pi integration should then be
handled at the system/pipeline level, not by replacing the K209 driver transport
with a fallback.
## Expected Hardware Test Result
With S2VNA listening on `4880` and IVI/Vendor VISA installed correctly, the
Python and C++ smoke tests should report:
```text
K209 IDN: Planar, K209, ...
K209 sweep OK: points=11, first_hz=10000000.000, last_hz=100000000.000
```
@@ -0,0 +1,261 @@
"""VISA HiSLIP driver for Compact-M K209 / S2VNA analyzers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import pyvisa
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
@dataclass(slots=True)
class CompactMK209InterleavedSweep:
"""Raw corrected K209 traces as interleaved REAL32 arrays."""
frequency_hz: np.ndarray
s11_values: np.ndarray
s21_values: np.ndarray
@dataclass(slots=True)
class CompactMK209Service:
"""Acquire corrected S11/S21 sweeps from a K209 analyzer through VISA HiSLIP."""
resource: str
timeout_ms: int = 20_000
preset_on_open: bool = True
visa_library: str = "@ivi"
_resource_manager: pyvisa.ResourceManager | None = field(init=False, default=None, repr=False)
_instrument: Any | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
"""Normalize constructor values."""
self.resource = str(self.resource).strip()
if not self.resource:
raise ValueError("K209 VISA resource must not be empty")
self.timeout_ms = int(self.timeout_ms)
if self.timeout_ms <= 0:
raise ValueError("K209 timeout_ms must be > 0")
self.visa_library = str(self.visa_library).strip() or "@ivi"
if self.visa_library == "@py" or self.visa_library.endswith("@py"):
raise ValueError("K209 requires an IVI/Vendor VISA backend, not pyvisa-py")
@property
def is_open(self) -> bool:
"""Return whether the VISA session is open."""
return self._instrument is not None
def open(self) -> None:
"""Open VISA resource and apply stored sweep settings when available."""
if self._instrument is not None:
return
try:
self._resource_manager = pyvisa.ResourceManager(self.visa_library)
self._instrument = self._resource_manager.open_resource(self.resource)
self._instrument.timeout = self.timeout_ms
self._instrument.write_termination = "\n"
self._instrument.read_termination = None
self._instrument.chunk_size = max(int(getattr(self._instrument, "chunk_size", 20_480)), 8 * 1024 * 1024)
self._instrument.write("*CLS")
if self._settings is not None:
self._apply_configuration(self._settings)
except Exception:
self.close()
raise
def close(self) -> None:
"""Close VISA sessions."""
if self._instrument is not None:
self._instrument.close()
self._instrument = None
if self._resource_manager is not None:
self._resource_manager.close()
self._resource_manager = None
def __enter__(self) -> CompactMK209Service:
"""Open and return this service."""
self.open()
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
"""Close VISA resources."""
self.close()
def query_identity(self) -> str:
"""Read analyzer identity string."""
instrument = self._require_instrument()
return str(instrument.query("*IDN?")).strip()
def query_system_error(self) -> str:
"""Read one analyzer SCPI error queue entry."""
instrument = self._require_instrument()
return str(instrument.query("SYST:ERR?")).strip()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
if self._instrument is None:
return
self._apply_configuration(sweep)
def read_device_limits(self) -> dict[str, float | int]:
"""Read analyzer limits through SCPI capability/service queries."""
instrument = self._require_instrument()
return {
"min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")),
"max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")),
"min_ifbw_hz": float(instrument.query("SYST:CAP:IFBW:MIN?")),
"max_ifbw_hz": float(instrument.query("SYST:CAP:IFBW:MAX?")),
"max_points": int(float(instrument.query("SERV:SWE:POIN?"))),
"min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")),
"max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")),
}
def acquire_interleaved(self) -> CompactMK209InterleavedSweep:
"""Acquire one corrected sweep without converting interleaved arrays."""
if self._settings is None:
raise RuntimeError("K209 service is not configured")
if self._frequency_hz is None:
raise RuntimeError("K209 frequency axis is not configured")
points = int(self._settings.points)
s11_values, s21_values = self._query_sweep_trace_pair(points)
return CompactMK209InterleavedSweep(
frequency_hz=self._frequency_hz,
s11_values=s11_values,
s21_values=s21_values,
)
def acquire(self) -> SweepResult:
"""Acquire one corrected S11/S21 sweep."""
raw = self.acquire_interleaved()
return SweepResult(
x=raw.frequency_hz.copy(),
traces={
"s11": self._complex_from_interleaved(raw.s11_values),
"s21": self._complex_from_interleaved(raw.s21_values),
},
)
def _apply_configuration(self, sweep: RadarSweepModel) -> None:
instrument = self._require_instrument()
if self.preset_on_open:
instrument.write("SYST:PRES")
self._expect_opc("*OPC?", context="K209 preset")
instrument.write(f"SENS:FREQ:STAR {float(sweep.start_hz):.9f}")
instrument.write(f"SENS:FREQ:STOP {float(sweep.stop_hz):.9f}")
instrument.write(f"SENS:SWE:POIN {int(sweep.points)}")
instrument.write("SENS:SWE:POIN:TIME 0")
instrument.write(f"SENS:BAND {float(sweep.if_bandwidth_hz):.9f}")
instrument.write(f"SOUR:POW {float(sweep.power_dbm):.3f}")
instrument.write("SENS:AVER OFF")
instrument.write("CALC:PAR1:DEF S21")
instrument.write("CALC:PAR1:SEL")
instrument.write("FORM:DATA REAL32")
instrument.write("FORM:BORD SWAP")
instrument.write("INIT:CONT ON")
instrument.write("TRIG:SOUR BUS")
self._expect_opc("*OPC?", context="K209 setup")
self._frequency_hz = self._query_float32_array("SENS:FREQ:DATA?", int(sweep.points))
def _expect_opc(self, command: str, *, context: str) -> None:
instrument = self._require_instrument()
response = str(instrument.query(command)).strip()
if response != "1":
raise RuntimeError(f"{context} returned unexpected *OPC? response: {response!r}")
def _query_float32_array(self, command: str, expected_values: int) -> np.ndarray:
instrument = self._require_instrument()
instrument.write(command)
return self._read_float32_block(command, expected_values)
def _query_sweep_trace_pair(self, points: int) -> tuple[np.ndarray, np.ndarray]:
instrument = self._require_instrument()
instrument.write("TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21")
response = self._read_ascii_token()
if response != "1":
raise RuntimeError(f"K209 sweep returned unexpected *OPC? response: {response!r}")
return (
self._read_float32_block("SENS:DATA:CORR? S11", points * 2),
self._read_float32_block("SENS:DATA:CORR? S21", points * 2),
)
def _read_ascii_token(self) -> str:
token = bytearray()
while True:
byte = self._read_response_bytes(1)
if byte in (b";", b"\n", b"\r"):
if token:
return token.decode("ascii").strip()
continue
token.extend(byte)
if len(token) > 64 * 1024:
raise RuntimeError("K209 ASCII response token is too long")
def _read_float32_block(self, context: str, expected_values: int) -> np.ndarray:
marker = self._read_response_bytes(1)
while marker in (b";", b"\n", b"\r"):
marker = self._read_response_bytes(1)
if marker != b"#":
raise RuntimeError(f"K209 response for {context!r} does not start with IEEE block marker")
width = self._read_response_bytes(1)
if width != b"8":
raise RuntimeError(f"K209 response for {context!r} uses unsupported IEEE block header width")
payload_size = int(self._read_response_bytes(8).decode("ascii"))
expected_size = expected_values * np.dtype(np.float32).itemsize
if payload_size != expected_size:
raise RuntimeError(
f"K209 response for {context!r} returned {payload_size} payload bytes, "
f"expected {expected_size}"
)
payload = self._read_response_bytes(payload_size)
array = np.frombuffer(payload, dtype="<f4")
if array.size != expected_values:
raise RuntimeError(
f"K209 response for {context!r} returned {array.size} float32 values, "
f"expected {expected_values}"
)
return array
def _read_response_bytes(self, count: int) -> bytes:
instrument = self._require_instrument()
data = instrument.read_bytes(count, break_on_termchar=False)
if len(data) != count:
raise RuntimeError(f"K209 response ended after {len(data)} bytes, expected {count}")
return data
def _require_instrument(self) -> Any:
if self._instrument is None:
raise RuntimeError("K209 VISA instrument is not open")
return self._instrument
@staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None:
if int(sweep.points) < 2:
raise ValueError("K209 sweep points must be >= 2")
if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("K209 sweep stop_hz must be >= start_hz")
if float(sweep.if_bandwidth_hz) <= 0.0:
raise ValueError("K209 IF bandwidth must be > 0")
@staticmethod
def _complex_from_interleaved(values: np.ndarray) -> np.ndarray:
if values.size % 2 != 0:
raise RuntimeError("K209 complex trace payload has odd scalar count")
reshaped = np.asarray(values, dtype=np.float32).reshape((-1, 2))
return (reshaped[:, 0] + 1j * reshaped[:, 1]).astype(np.complex64)
+1
View File
@@ -202,6 +202,7 @@ class RunConfigModel:
LIBREVNA_MODEL = "librevna" LIBREVNA_MODEL = "librevna"
LIBREVNA_MULTI_MODEL = "librevna_multi" LIBREVNA_MULTI_MODEL = "librevna_multi"
COMPACT_M_K209_MODEL = "compact_m_k209"
MULTI_DEVICE_INPUT_POSITIONS = 4 MULTI_DEVICE_INPUT_POSITIONS = 4
MULTI_DEVICE_OUTPUT_POSITIONS = 2 MULTI_DEVICE_OUTPUT_POSITIONS = 2
+92
View File
@@ -0,0 +1,92 @@
"""Standalone smoke test for Compact-M K209 VISA HiSLIP acquisition."""
from __future__ import annotations
import argparse
import numpy as np
from python_app.hardware_full.compact_m_k209_service import CompactMK209Service
from python_app.models.run_config_model import RadarSweepModel
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Acquire one K209 sweep through VISA HiSLIP")
parser.add_argument(
"--resource",
required=True,
help=(
"S2VNA VISA resource, e.g. TCPIP0::127.0.0.1::hislip0,4880::INSTR "
"when the USB-connected K209 is controlled by local S2VNA"
),
)
parser.add_argument("--start-hz", type=float, default=1_000_000.0)
parser.add_argument("--stop-hz", type=float, default=6_000_000_000.0)
parser.add_argument("--points", type=int, default=201)
parser.add_argument("--ifbw-hz", type=float, default=50_000.0)
parser.add_argument("--power-dbm", type=float, default=-10.0)
parser.add_argument("--timeout-ms", type=int, default=20_000)
parser.add_argument("--no-preset", action="store_true")
parser.add_argument(
"--visa-library",
default="@ivi",
help="PyVISA IVI backend specification, e.g. @ivi or /usr/lib/x86_64-linux-gnu/libvisa.so",
)
return parser.parse_args()
def _validate_result(result, expected_points: int) -> None:
if result.x.shape != (expected_points,):
raise RuntimeError(f"Unexpected frequency shape: {result.x.shape}")
s11 = result.trace("s11")
s21 = result.trace("s21")
if s11.shape != (expected_points,) or s21.shape != (expected_points,):
raise RuntimeError(f"Unexpected trace shapes: s11={s11.shape}, s21={s21.shape}")
if not np.all(np.isfinite(result.x)):
raise RuntimeError("Frequency axis contains non-finite values")
if np.any(np.diff(result.x) < 0.0):
raise RuntimeError("Frequency axis is not monotonic")
if not np.all(np.isfinite(s11.real)) or not np.all(np.isfinite(s11.imag)):
raise RuntimeError("S11 contains non-finite values")
if not np.all(np.isfinite(s21.real)) or not np.all(np.isfinite(s21.imag)):
raise RuntimeError("S21 contains non-finite values")
def main() -> int:
args = _parse_args()
sweep = RadarSweepModel(
start_hz=args.start_hz,
stop_hz=args.stop_hz,
points=args.points,
if_bandwidth_hz=args.ifbw_hz,
power_dbm=args.power_dbm,
)
service = CompactMK209Service(
resource=args.resource,
timeout_ms=args.timeout_ms,
preset_on_open=not args.no_preset,
visa_library=args.visa_library,
)
try:
service.open()
print(f"K209 IDN: {service.query_identity()}")
service.configure(sweep)
result = service.acquire()
_validate_result(result, args.points)
system_error = service.query_system_error()
if not system_error.startswith("0,"):
raise RuntimeError(f"K209 SCPI error after sweep: {system_error}")
print(
"K209 sweep OK: "
f"points={result.x.size}, first_hz={result.x[0]:.3f}, last_hz={result.x[-1]:.3f}, "
f"mean_abs_s11={np.mean(np.abs(result.trace('s11'))):.6g}, "
f"mean_abs_s21={np.mean(np.abs(result.trace('s21'))):.6g}"
)
finally:
service.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+206
View File
@@ -0,0 +1,206 @@
"""Measure Compact-M K209 sweep acquisition throughput through VISA HiSLIP."""
from __future__ import annotations
from dataclasses import dataclass
import statistics
import time
import numpy as np
from python_app.hardware_full.compact_m_k209_service import CompactMK209Service
from python_app.models.run_config_model import RadarSweepModel
RESOURCE = "TCPIP0::127.0.0.1::hislip0,4880::INSTR"
VISA_LIBRARY = "@ivi"
START_HZ = 100_000_000.0
STOP_HZ = 6_000_000_000.0
POINTS = 1501
IFBW_HZ = 10_000.0
POWER_DBM = -20.0
TIMEOUT_MS = 20_000
WARMUP_SWEEPS = 3
TIMED_SWEEPS = 20
PRESET_ON_OPEN = False
# False measures the device/transport hot path: one synchronized TRIG:SING/*OPC?
# message plus S11/S21 REAL32 reads.
# True also includes public SweepResult construction and complex array conversion.
INCLUDE_RESULT_CONVERSION = False
@dataclass(frozen=True, slots=True)
class BenchmarkResult:
"""Timing summary for repeated sweep acquisition."""
durations_s: list[float]
points: int
@property
def total_s(self) -> float:
"""Return total timed acquisition duration."""
return sum(self.durations_s)
@property
def sweeps_per_s(self) -> float:
"""Return completed sweeps per second."""
return len(self.durations_s) / self.total_s
@property
def points_per_s(self) -> float:
"""Return measured sweep points per second."""
return (len(self.durations_s) * self.points) / self.total_s
@property
def binary_payload_mb_per_s(self) -> float:
"""Return S11+S21 binary payload throughput, excluding SCPI headers."""
payload_bytes = len(self.durations_s) * self.points * 2 * 2 * np.dtype(np.float32).itemsize
return payload_bytes / self.total_s / 1_000_000.0
def _validate_config() -> None:
if POINTS < 2:
raise ValueError("POINTS must be >= 2")
if WARMUP_SWEEPS < 0:
raise ValueError("WARMUP_SWEEPS must be >= 0")
if TIMED_SWEEPS <= 0:
raise ValueError("TIMED_SWEEPS must be > 0")
if IFBW_HZ <= 0.0:
raise ValueError("IFBW_HZ must be > 0")
if STOP_HZ < START_HZ:
raise ValueError("STOP_HZ must be >= START_HZ")
if TIMEOUT_MS <= 0:
raise ValueError("TIMEOUT_MS must be > 0")
def _validate_interleaved(raw, expected_points: int) -> None:
if raw.frequency_hz.shape != (expected_points,):
raise RuntimeError(f"Unexpected frequency shape: {raw.frequency_hz.shape}")
if raw.s11_values.shape != (expected_points * 2,):
raise RuntimeError(f"Unexpected S11 shape: {raw.s11_values.shape}")
if raw.s21_values.shape != (expected_points * 2,):
raise RuntimeError(f"Unexpected S21 shape: {raw.s21_values.shape}")
if not np.all(np.isfinite(raw.frequency_hz)):
raise RuntimeError("Frequency axis contains non-finite values")
if np.any(np.diff(raw.frequency_hz) < 0.0):
raise RuntimeError("Frequency axis is not monotonic")
if not np.all(np.isfinite(raw.s11_values)):
raise RuntimeError("S11 contains non-finite values")
if not np.all(np.isfinite(raw.s21_values)):
raise RuntimeError("S21 contains non-finite values")
def _validate_result(result, expected_points: int) -> None:
if result.x.shape != (expected_points,):
raise RuntimeError(f"Unexpected frequency shape: {result.x.shape}")
for name in ("s11", "s21"):
values = result.trace(name)
if values.shape != (expected_points,):
raise RuntimeError(f"Unexpected {name.upper()} shape: {values.shape}")
if not np.all(np.isfinite(values.real)) or not np.all(np.isfinite(values.imag)):
raise RuntimeError(f"{name.upper()} contains non-finite values")
def _percentile(values: list[float], percentile: float) -> float:
sorted_values = sorted(values)
index = round((len(sorted_values) - 1) * percentile)
return sorted_values[index]
def _run_benchmark(service: CompactMK209Service, *, points: int, warmup: int, sweeps: int, convert: bool) -> BenchmarkResult:
acquire = service.acquire if convert else service.acquire_interleaved
first = acquire()
if convert:
_validate_result(first, points)
else:
_validate_interleaved(first, points)
for _ in range(warmup):
acquire()
durations_s: list[float] = []
for _ in range(sweeps):
start_ns = time.perf_counter_ns()
last = acquire()
end_ns = time.perf_counter_ns()
durations_s.append((end_ns - start_ns) / 1_000_000_000.0)
if convert:
_validate_result(last, points)
else:
_validate_interleaved(last, points)
return BenchmarkResult(durations_s=durations_s, points=points)
def _print_limits(limits: dict[str, float | int]) -> None:
print(
"K209 limits: "
f"frequency={limits['min_frequency_hz']:.0f}..{limits['max_frequency_hz']:.0f} Hz, "
f"IFBW={limits['min_ifbw_hz']:.0f}..{limits['max_ifbw_hz']:.0f} Hz, "
f"power={limits['min_power_dbm']:.1f}..{limits['max_power_dbm']:.1f} dBm, "
f"max_points={limits['max_points']}"
)
def main() -> int:
_validate_config()
sweep = RadarSweepModel(
start_hz=START_HZ,
stop_hz=STOP_HZ,
points=POINTS,
if_bandwidth_hz=IFBW_HZ,
power_dbm=POWER_DBM,
)
service = CompactMK209Service(
resource=RESOURCE,
timeout_ms=TIMEOUT_MS,
preset_on_open=PRESET_ON_OPEN,
visa_library=VISA_LIBRARY,
)
try:
service.open()
print(f"K209 IDN: {service.query_identity()}")
_print_limits(service.read_device_limits())
service.configure(sweep)
print(
"Benchmark settings: "
f"start_hz={START_HZ:.3f}, stop_hz={STOP_HZ:.3f}, "
f"points={POINTS}, ifbw_hz={IFBW_HZ:.3f}, power_dbm={POWER_DBM:.3f}, "
f"warmup={WARMUP_SWEEPS}, sweeps={TIMED_SWEEPS}, "
f"mode={'SweepResult' if INCLUDE_RESULT_CONVERSION else 'raw interleaved REAL32'}"
)
result = _run_benchmark(
service,
points=POINTS,
warmup=WARMUP_SWEEPS,
sweeps=TIMED_SWEEPS,
convert=INCLUDE_RESULT_CONVERSION,
)
system_error = service.query_system_error()
if not system_error.startswith("0,"):
raise RuntimeError(f"K209 SCPI error after benchmark: {system_error}")
finally:
service.close()
durations_ms = [value * 1_000.0 for value in result.durations_s]
print("Benchmark result:")
print(f" total_s={result.total_s:.6f}")
print(f" sweep_mean_ms={statistics.fmean(durations_ms):.3f}")
print(f" sweep_median_ms={statistics.median(durations_ms):.3f}")
print(f" sweep_min_ms={min(durations_ms):.3f}")
print(f" sweep_max_ms={max(durations_ms):.3f}")
print(f" sweep_p95_ms={_percentile(durations_ms, 0.95):.3f}")
print(f" sweeps_per_s={result.sweeps_per_s:.3f}")
print(f" points_per_s={result.points_per_s:.1f}")
print(f" s11_s21_payload_mb_per_s={result.binary_payload_mb_per_s:.3f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -1,5 +1,6 @@
numpy>=1.26,<3 numpy>=1.26,<3
libusb1>=3.1 libusb1>=3.1
pyvisa>=1.14
PyQt6>=6.6 PyQt6>=6.6
pyqtgraph>=0.13.7 pyqtgraph>=0.13.7
rpi-hardware-pwm>=0.2.2,<1 rpi-hardware-pwm>=0.2.2,<1