diff --git a/Makefile b/Makefile index 2bb5860..5ae3bd7 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ CXX := g++ CXXFLAGS := -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread -MMD -MP LDFLAGS := -pthread -lrt ORCH_LDFLAGS := $(LDFLAGS) -lusb-1.0 +VISA_CXXFLAGS ?= -I/usr/include/ni-visa +VISA_LDFLAGS := $(LDFLAGS) -lvisa BUILD_DIR := build BIN_DIR := $(BUILD_DIR)/bin @@ -79,7 +81,7 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS) $(BUILD_DIR)/%.o: %.cpp @mkdir -p $(dir $@) - $(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@ + $(CXX) $(CXXFLAGS) $(VISA_CXXFLAGS) $(INCLUDES) -c $< -o $@ -include $(DEPFILES) diff --git a/S2VNA Operating and Programing Manual ver 26.1 2.pdf b/S2VNA Operating and Programing Manual ver 26.1 2.pdf new file mode 100644 index 0000000..0b2f724 Binary files /dev/null and b/S2VNA Operating and Programing Manual ver 26.1 2.pdf differ diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/compact_m_k209/compact_m_k209_driver.cpp b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/compact_m_k209/compact_m_k209_driver.cpp new file mode 100644 index 0000000..fd86703 --- /dev/null +++ b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/compact_m_k209/compact_m_k209_driver.cpp @@ -0,0 +1,414 @@ +#include "../compact_m_k209_driver.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(const_cast(resource.c_str())); +} + +[[nodiscard]] auto visa_buffer(std::uint8_t* data) -> ViBuf { + return reinterpret_cast(data); +} + +[[nodiscard]] auto visa_const_buffer(const char* data) -> ViBuf { + return reinterpret_cast(const_cast(data)); +} + +[[nodiscard]] auto is_line_ending(std::uint8_t value) -> bool { + return value == static_cast('\n') || value == static_cast('\r'); +} + +[[nodiscard]] auto is_response_separator(std::uint8_t value) -> bool { + return is_line_ending(value) || value == static_cast(';'); +} + +[[nodiscard]] auto checked_vi_count(std::uint64_t value) -> ViUInt32 { + const auto max_count = static_cast(std::numeric_limits::max()); + return static_cast(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(settings_.timeout_ms)), + "set K209 VISA timeout" + ); + check_status( + viSetAttribute(instrument_, VI_ATTR_TERMCHAR_EN, static_cast(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(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 { + 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> { + 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 { + 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(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('\n')) { + break; + } + if (byte != static_cast('\r')) { + line.push_back(static_cast(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 { + require_open(); + + std::vector 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 marker = read_byte(); + while (is_response_separator(marker)) { + marker = read_byte(); + } + if (marker != static_cast('#')) { + throw std::runtime_error("K209 binary response does not start with IEEE block marker '#'"); + } + + const auto digit = read_byte(); + if (digit != static_cast('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(byte))) { + throw std::runtime_error("K209 binary response has non-numeric payload size"); + } + payload_size = (payload_size * 10ULL) + static_cast(byte - static_cast('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 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 bytes) -> std::vector { + if ((bytes.size() % kFloatBytes) != 0U) { + throw std::runtime_error("K209 binary float payload is not aligned to 4 bytes"); + } + + std::vector 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(bytes[offset]) | + (static_cast(bytes[offset + 1U]) << 8U) | + (static_cast(bytes[offset + 2U]) << 16U) | + (static_cast(bytes[offset + 3U]) << 24U); + std::memcpy(&values[index], &bits, sizeof(float)); + } + return values; +} + +auto CompactMK209Driver::complex_trace_from_interleaved(const std::vector& values) + -> std::vector { + if ((values.size() % kComplexScalarCount) != 0U) { + throw std::runtime_error("K209 complex trace payload has odd scalar count"); + } + + std::vector 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 diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/compact_m_k209_driver.hpp b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/compact_m_k209_driver.hpp new file mode 100644 index 0000000..3bd86b8 --- /dev/null +++ b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/compact_m_k209_driver.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#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; + [[nodiscard]] auto query_sweep_trace_pair(std::uint32_t points) + -> std::pair, std::vector>; + [[nodiscard]] auto read_float_array_response(const std::string& context, std::uint32_t expected_values) + -> std::vector; + + [[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; + [[nodiscard]] auto read_ieee_block() -> std::vector; + + 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 bytes) -> std::vector; + [[nodiscard]] static auto complex_trace_from_interleaved(const std::vector& values) + -> std::vector; + [[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 frequency_hz_{}; + bool is_open_ = false; + ViSession resource_manager_ = VI_NULL; + ViSession instrument_ = VI_NULL; +}; + +} // namespace radar::drivers diff --git a/docs/k209_setup.md b/docs/k209_setup.md new file mode 100644 index 0000000..be2b1cb --- /dev/null +++ b/docs/k209_setup.md @@ -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 +``` diff --git a/python_app/hardware_full/compact_m_k209_service.py b/python_app/hardware_full/compact_m_k209_service.py new file mode 100644 index 0000000..39e4a5d --- /dev/null +++ b/python_app/hardware_full/compact_m_k209_service.py @@ -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=" 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) diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 40b1b83..5347cd2 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -202,6 +202,7 @@ class RunConfigModel: LIBREVNA_MODEL = "librevna" LIBREVNA_MULTI_MODEL = "librevna_multi" + COMPACT_M_K209_MODEL = "compact_m_k209" MULTI_DEVICE_INPUT_POSITIONS = 4 MULTI_DEVICE_OUTPUT_POSITIONS = 2 diff --git a/python_app/scripts/k209_smoke_test.py b/python_app/scripts/k209_smoke_test.py new file mode 100644 index 0000000..26842d1 --- /dev/null +++ b/python_app/scripts/k209_smoke_test.py @@ -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()) diff --git a/python_app/scripts/k209_sweep_benchmark.py b/python_app/scripts/k209_sweep_benchmark.py new file mode 100644 index 0000000..0c29ee2 --- /dev/null +++ b/python_app/scripts/k209_sweep_benchmark.py @@ -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()) diff --git a/requirements.txt b/requirements.txt index 89e90ee..48de90b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ numpy>=1.26,<3 libusb1>=3.1 +pyvisa>=1.14 PyQt6>=6.6 pyqtgraph>=0.13.7 rpi-hardware-pwm>=0.2.2,<1