diff --git a/Makefile b/Makefile index 5ae3bd7..a5a83cc 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ ORCH_SOURCES := \ data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp \ data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_transport.cpp \ data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_protocol.cpp \ + data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209/remote_compact_m_k209_driver.cpp \ data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp \ data_acq_and_processing/sweep_orchestrator/device_drivers/switches/hmc349a_minimal_driver.cpp \ data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp \ diff --git a/README.md b/README.md new file mode 100644 index 0000000..47dfe77 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# radar_system + +Radar acquisition and processing system for single LibreVNA, synchronized +multi-device LibreVNA, and Compact-M K209/S2VNA setups. + +Start here: + +- [Operation Modes](docs/operation_modes.md): what to run on each machine for + `librevna`, `librevna_multi`, and `compact_m_k209`. +- [Run Config Reference](docs/run_config.md): `run_config.json` fields and + example files. +- [K209 Setup](docs/k209_setup.md): S2VNA, VISA, K209 limits, smoke tests, and + Raspberry Pi remote mode details. + +Common local setup: + +```bash +cd /path/to/radar_system +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r requirements.txt +make +``` + +Run the GUI: + +```bash +.venv/bin/python -m python_app.gui.main +``` + diff --git a/data_acq_and_processing/common_cpp/config/include/run_config.hpp b/data_acq_and_processing/common_cpp/config/include/run_config.hpp index d06ac8d..74aa41a 100644 --- a/data_acq_and_processing/common_cpp/config/include/run_config.hpp +++ b/data_acq_and_processing/common_cpp/config/include/run_config.hpp @@ -31,6 +31,8 @@ struct RadarConfig { // Radar device identity and runtime mode. std::string model = "librevna"; std::string serial{}; + std::string remote_host = "127.0.0.1"; + std::uint32_t remote_port = 50209; DriverMode driver_mode = DriverMode::Mock; float mock_signal_hz = 5'000'000.0F; RadarSweepSettings sweep{}; diff --git a/data_acq_and_processing/common_cpp/config/src/run_config.cpp b/data_acq_and_processing/common_cpp/config/src/run_config.cpp index 767e2e9..7d61ea5 100644 --- a/data_acq_and_processing/common_cpp/config/src/run_config.cpp +++ b/data_acq_and_processing/common_cpp/config/src/run_config.cpp @@ -413,6 +413,14 @@ auto load_run_config(const std::string& path) -> RunConfig { const auto* radar_obj = as_object(required_field(*root_obj, "radar"), "radar"); config.radar.model = optional_string(*radar_obj, "model", "librevna"); config.radar.serial = optional_string(*radar_obj, "serial", ""); + config.radar.remote_host = optional_string(*radar_obj, "remote_host", "127.0.0.1"); + config.radar.remote_port = optional_u32(*radar_obj, "remote_port", 50209); + if (config.radar.remote_host.empty()) { + throw std::runtime_error("radar.remote_host must not be empty"); + } + if (config.radar.remote_port == 0U || config.radar.remote_port > 65535U) { + throw std::runtime_error("radar.remote_port must be in 1..65535"); + } config.radar.driver_mode = parse_driver_mode(optional_string(*radar_obj, "driver_mode", "mock")); config.radar.mock_signal_hz = optional_f32(*radar_obj, "mock_signal_hz", 5'000'000.0F); diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209/remote_compact_m_k209_driver.cpp b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209/remote_compact_m_k209_driver.cpp new file mode 100644 index 0000000..06024d6 --- /dev/null +++ b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209/remote_compact_m_k209_driver.cpp @@ -0,0 +1,271 @@ +#include "../remote_compact_m_k209_driver.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace radar::drivers { +namespace { + +constexpr std::uint8_t kCommandConfigure = static_cast('C'); +constexpr std::uint8_t kCommandAcquire = static_cast('A'); +constexpr std::uint8_t kStatusOk = static_cast('O'); +constexpr std::uint8_t kStatusError = static_cast('E'); + +[[nodiscard]] auto system_error_message(const std::string& context) -> std::string { + return context + ": " + std::strerror(errno); +} + +void append_u32_be(std::vector& buffer, std::uint32_t value) { + buffer.push_back(static_cast((value >> 24U) & 0xFFU)); + buffer.push_back(static_cast((value >> 16U) & 0xFFU)); + buffer.push_back(static_cast((value >> 8U) & 0xFFU)); + buffer.push_back(static_cast(value & 0xFFU)); +} + +void append_u64_be(std::vector& buffer, std::uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) { + buffer.push_back(static_cast((value >> static_cast(shift)) & 0xFFU)); + } +} + +void append_double_be(std::vector& buffer, double value) { + std::uint64_t bits = 0; + static_assert(sizeof(bits) == sizeof(value)); + std::memcpy(&bits, &value, sizeof(bits)); + append_u64_be(buffer, bits); +} + +[[nodiscard]] auto parse_u32_be(const std::uint8_t* data) -> std::uint32_t { + return (static_cast(data[0]) << 24U) | + (static_cast(data[1]) << 16U) | + (static_cast(data[2]) << 8U) | + static_cast(data[3]); +} + +void send_all(int socket_fd, const void* data, std::size_t size) { + const auto* cursor = static_cast(data); + auto remaining = size; + while (remaining > 0U) { + const auto sent = ::send(socket_fd, cursor, remaining, MSG_NOSIGNAL); + if (sent < 0) { + throw std::runtime_error(system_error_message("send to K209 remote server failed")); + } + if (sent == 0) { + throw std::runtime_error("send to K209 remote server returned zero bytes"); + } + cursor += sent; + remaining -= static_cast(sent); + } +} + +void recv_exact(int socket_fd, void* data, std::size_t size) { + auto* cursor = static_cast(data); + auto remaining = size; + while (remaining > 0U) { + const auto received = ::recv(socket_fd, cursor, remaining, 0); + if (received < 0) { + throw std::runtime_error(system_error_message("read from K209 remote server failed")); + } + if (received == 0) { + throw std::runtime_error("K209 remote server closed the connection"); + } + cursor += received; + remaining -= static_cast(received); + } +} + +[[nodiscard]] auto recv_u32(int socket_fd) -> std::uint32_t { + std::uint8_t bytes[4]{}; + recv_exact(socket_fd, bytes, sizeof(bytes)); + return parse_u32_be(bytes); +} + +void read_status(int socket_fd) { + std::uint8_t status = 0; + recv_exact(socket_fd, &status, 1); + if (status == kStatusOk) { + return; + } + if (status == kStatusError) { + const auto message_size = recv_u32(socket_fd); + std::string message(message_size, '\0'); + if (message_size > 0U) { + recv_exact(socket_fd, message.data(), message.size()); + } + throw std::runtime_error("K209 remote server error: " + message); + } + throw std::runtime_error("K209 remote server returned invalid status byte"); +} + +[[nodiscard]] auto recv_float32_array(int socket_fd, std::uint32_t expected_values, const std::string& context) + -> std::vector { + const auto payload_size = recv_u32(socket_fd); + const auto expected_size = expected_values * static_cast(sizeof(float)); + if (payload_size != expected_size) { + throw std::runtime_error( + "K209 remote " + context + " payload has " + std::to_string(payload_size) + + " bytes, expected " + std::to_string(expected_size) + ); + } + + std::vector values(expected_values); + if (payload_size > 0U) { + recv_exact(socket_fd, values.data(), payload_size); + } + return values; +} + +[[nodiscard]] auto connect_socket(const std::string& host, std::uint32_t port, std::uint32_t timeout_ms) -> int { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + + addrinfo* result = nullptr; + const auto port_text = std::to_string(port); + const auto rc = ::getaddrinfo(host.c_str(), port_text.c_str(), &hints, &result); + if (rc != 0) { + throw std::runtime_error("getaddrinfo failed for K209 remote host " + host + ": " + ::gai_strerror(rc)); + } + + int connected_fd = -1; + std::string last_error; + for (auto* item = result; item != nullptr; item = item->ai_next) { + const int fd = ::socket(item->ai_family, item->ai_socktype, item->ai_protocol); + if (fd < 0) { + last_error = system_error_message("socket"); + continue; + } + + const int one = 1; + static_cast(::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one))); + timeval timeout{}; + timeout.tv_sec = static_cast(timeout_ms / 1000U); + timeout.tv_usec = static_cast((timeout_ms % 1000U) * 1000U); + static_cast(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); + + if (::connect(fd, item->ai_addr, item->ai_addrlen) == 0) { + connected_fd = fd; + break; + } + last_error = system_error_message("connect"); + ::close(fd); + } + ::freeaddrinfo(result); + + if (connected_fd < 0) { + throw std::runtime_error("Failed to connect to K209 remote server " + host + ":" + port_text + ": " + last_error); + } + return connected_fd; +} + +[[nodiscard]] auto complex_from_interleaved(const std::vector& values, std::uint32_t points, const std::string& context) + -> std::vector { + if (values.size() != static_cast(points) * 2U) { + throw std::runtime_error("K209 remote " + context + " returned unexpected scalar count"); + } + + std::vector output(points); + for (std::uint32_t index = 0; index < points; ++index) { + output[index] = ipc::Complex32{ + .re = values[static_cast(index) * 2U], + .im = values[static_cast(index) * 2U + 1U], + }; + } + return output; +} + +} // namespace + +RemoteCompactMK209Driver::RemoteCompactMK209Driver(RemoteCompactMK209DriverSettings settings) + : settings_(std::move(settings)) { + if (settings_.host.empty()) { + throw std::runtime_error("K209 remote host must not be empty"); + } + if (settings_.port == 0U || settings_.port > 65535U) { + throw std::runtime_error("K209 remote port must be in 1..65535"); + } + if (settings_.timeout_ms == 0U) { + throw std::runtime_error("K209 remote timeout_ms must be > 0"); + } +} + +RemoteCompactMK209Driver::~RemoteCompactMK209Driver() { + try { + close(); + } catch (...) { + } +} + +void RemoteCompactMK209Driver::open() { + if (socket_fd_ >= 0) { + return; + } + + socket_fd_ = connect_socket(settings_.host, settings_.port, settings_.timeout_ms); + + try { + std::vector payload{}; + payload.reserve(1U + 8U + 8U + 4U + 8U + 8U); + payload.push_back(kCommandConfigure); + append_double_be(payload, static_cast(settings_.sweep.start_hz)); + append_double_be(payload, static_cast(settings_.sweep.stop_hz)); + append_u32_be(payload, settings_.sweep.points); + append_double_be(payload, static_cast(settings_.sweep.if_bandwidth_hz)); + append_double_be(payload, static_cast(settings_.sweep.power_dbm)); + send_all(socket_fd_, payload.data(), payload.size()); + + read_status(socket_fd_); + const auto points = recv_u32(socket_fd_); + if (points != settings_.sweep.points) { + throw std::runtime_error("K209 remote server returned unexpected configured point count"); + } + frequency_hz_ = recv_float32_array(socket_fd_, points, "frequency"); + } catch (...) { + close(); + throw; + } +} + +void RemoteCompactMK209Driver::close() { + if (socket_fd_ >= 0) { + ::close(socket_fd_); + socket_fd_ = -1; + } + frequency_hz_.clear(); +} + +auto RemoteCompactMK209Driver::acquire_sweep() -> SweepTrace { + if (socket_fd_ < 0) { + throw std::runtime_error("K209 remote driver is not open"); + } + const std::uint8_t command = kCommandAcquire; + send_all(socket_fd_, &command, 1); + read_status(socket_fd_); + + const auto points = recv_u32(socket_fd_); + if (points != settings_.sweep.points) { + throw std::runtime_error("K209 remote server returned unexpected sweep point count"); + } + const auto s11_values = recv_float32_array(socket_fd_, points * 2U, "S11"); + const auto s21_values = recv_float32_array(socket_fd_, points * 2U, "S21"); + + return SweepTrace{ + .frequency_hz = frequency_hz_, + .s11 = complex_from_interleaved(s11_values, points, "S11"), + .s21 = complex_from_interleaved(s21_values, points, "S21"), + }; +} + +} // namespace radar::drivers diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209_driver.hpp b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209_driver.hpp new file mode 100644 index 0000000..3f8a827 --- /dev/null +++ b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/remote_compact_m_k209_driver.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +#include "radar_driver.hpp" +#include "run_config.hpp" + +namespace radar::drivers { + +struct RemoteCompactMK209DriverSettings { + std::string host = "127.0.0.1"; + std::uint32_t port = 50209; + config::RadarSweepSettings sweep{}; + std::uint32_t timeout_ms = 20'000; +}; + +class RemoteCompactMK209Driver final : public RadarDriver { + public: + explicit RemoteCompactMK209Driver(RemoteCompactMK209DriverSettings settings); + ~RemoteCompactMK209Driver() override; + + void open() override; + void close() override; + [[nodiscard]] auto acquire_sweep() -> SweepTrace override; + + private: + RemoteCompactMK209DriverSettings settings_{}; + int socket_fd_ = -1; + std::vector frequency_hz_{}; +}; + +} // namespace radar::drivers diff --git a/data_acq_and_processing/sweep_orchestrator/src/main.cpp b/data_acq_and_processing/sweep_orchestrator/src/main.cpp index c6d3fcc..954ac29 100644 --- a/data_acq_and_processing/sweep_orchestrator/src/main.cpp +++ b/data_acq_and_processing/sweep_orchestrator/src/main.cpp @@ -9,6 +9,8 @@ #include "h7992_minimal_driver.hpp" #include "hmc349a_minimal_driver.hpp" #include "librevna_minimal_driver.hpp" +#include "radar_driver.hpp" +#include "remote_compact_m_k209_driver.hpp" #include "run_config.hpp" #include "shm_ring.hpp" #include "sweep_orchestrator.hpp" @@ -16,6 +18,9 @@ namespace { constexpr const char* kDefaultConfigPath = "run_config.json"; +constexpr const char* kLibreVnaModel = "librevna"; +constexpr const char* kLibreVnaMultiModel = "librevna_multi"; +constexpr const char* kCompactMK209Model = "compact_m_k209"; std::atomic g_stop_requested{false}; void signal_handler(int /*signal*/) { @@ -40,13 +45,38 @@ void install_signal_handlers() { return config_path; } -[[nodiscard]] auto make_radar_driver(const radar::config::RadarConfig& config) -> radar::drivers::LibreVnaMinimalDriver { - return radar::drivers::LibreVnaMinimalDriver({ - .mode = config.driver_mode, - .serial = config.serial, - .sweep = config.sweep, - .mock_signal_hz = config.mock_signal_hz, - }); +[[nodiscard]] auto make_radar_driver(const radar::config::RadarConfig& config) + -> std::unique_ptr { + if (config.model == kLibreVnaModel || config.model.empty()) { + return std::make_unique( + radar::drivers::LibreVnaMinimalDriverSettings{ + .mode = config.driver_mode, + .serial = config.serial, + .sweep = config.sweep, + .mock_signal_hz = config.mock_signal_hz, + } + ); + } + + if (config.model == kCompactMK209Model) { + if (config.driver_mode != radar::config::DriverMode::Native) { + throw std::runtime_error("compact_m_k209 requires radar.driver_mode='native'"); + } + return std::make_unique( + radar::drivers::RemoteCompactMK209DriverSettings{ + .host = config.remote_host, + .port = config.remote_port, + .sweep = config.sweep, + .timeout_ms = 20'000, + } + ); + } + + if (config.model == kLibreVnaMultiModel) { + throw std::runtime_error("librevna_multi is handled by python_app.scripts.multi_device_raw_producer"); + } + + throw std::runtime_error("Unsupported radar.model for sweep_orchestrator: " + config.model); } [[nodiscard]] auto make_h7992_driver(const radar::config::SwitchConfig& config) @@ -120,7 +150,7 @@ int main(int argc, char** argv) { radar::acq::SweepOrchestrator orchestrator( config, - radar_driver, + *radar_driver, *input_switch, *output_switch, raw_ring, diff --git a/docs/k209_setup.md b/docs/k209_setup.md index be2b1cb..b1d2023 100644 --- a/docs/k209_setup.md +++ b/docs/k209_setup.md @@ -7,6 +7,11 @@ The production path is: K209 --USB-C--> S2VNA --HiSLIP/VISA--> radar_system ``` +For complete run-mode instructions, including what runs on the x86_64 S2VNA +computer and what runs on Raspberry Pi, see +[`docs/operation_modes.md`](operation_modes.md). For `run_config.json` fields, +see [`docs/run_config.md`](run_config.md). + There is no direct USB driver for K209 in this project. Do not use mock transports, socket fallbacks, or `pyvisa-py` for the K209 path. The required transport dependency is an IVI/Vendor VISA implementation that provides both @@ -140,6 +145,31 @@ TCPIP0::127.0.0.1::hislip0,4880::INSTR If S2VNA runs on another machine, replace `127.0.0.1` with that machine's IP address. +## Remote Raspberry Pi Mode + +For Raspberry Pi runs, keep S2VNA and NI-VISA on the x86_64 computer connected +to the K209, and run only the project pipeline/GPIO on the Raspberry Pi. + +On the x86_64 computer with S2VNA running: + +```bash +cd /path/to/radar_system +.venv/bin/python -m python_app.scripts.k209_remote_server --host 0.0.0.0 --port 50209 +``` + +On the Raspberry Pi, set the K209 config to the server address: + +```json +"radar": { + "model": "compact_m_k209", + "remote_host": "192.168.1.10", + "remote_port": 50209, + "driver_mode": "native" +} +``` + +The Raspberry Pi does not need S2VNA or NI-VISA for this mode. + ## Python Smoke Test Use the project virtual environment: @@ -263,9 +293,9 @@ are available for ARM64: 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. +server on an Ubuntu x86_64 machine and use the remote K209 mode documented +above. In that mode, Raspberry Pi runs the project pipeline and GPIO switch +drivers, while the x86_64 machine runs S2VNA and the K209 remote server. ## Expected Hardware Test Result diff --git a/docs/operation_modes.md b/docs/operation_modes.md new file mode 100644 index 0000000..b622269 --- /dev/null +++ b/docs/operation_modes.md @@ -0,0 +1,213 @@ +# Operation Modes + +The active radar backend is selected manually in JSON by `radar.model`. +The GUI does not expose a model selector. + +Available models: + +```text +librevna +librevna_multi +compact_m_k209 +``` + +Example configs in the repository root: + +```text +run_config_librevna.example.json +run_config_librevna_multi.example.json +run_config_compact_m_k209.example.json +run_config_compact_m_k209_local_mock_switches.example.json +``` + +## Common Commands + +Build native binaries: + +```bash +cd /path/to/radar_system +make +``` + +Run the GUI: + +```bash +.venv/bin/python -m python_app.gui.main +``` + +Run a single acquisition producer manually: + +```bash +build/bin/sweep_orchestrator --config run_config.json +``` + +The GUI process supervisor starts the correct producer automatically: + +- `librevna` -> `build/bin/sweep_orchestrator` +- `compact_m_k209` -> `build/bin/sweep_orchestrator` +- `librevna_multi` -> `python_app.scripts.multi_device_raw_producer` + +## Single LibreVNA + +Use this mode when one LibreVNA is connected directly over USB to the machine +running the project. + +Config: + +```json +"radar": { + "model": "librevna", + "serial": "", + "driver_mode": "native" +} +``` + +Notes: + +- Empty `serial` means use the first compatible LibreVNA found. +- Set `serial` when multiple LibreVNAs are connected. +- `driver_mode: "native"` uses the direct USB LibreVNA driver. +- `driver_mode: "mock"` generates synthetic radar data for UI/development. +- Switch GPIO is controlled by the same machine unless switch `driver_mode` is + set to `mock`. + +Typical local check without GPIO: + +```bash +cp run_config_librevna.example.json /tmp/librevna_mock_switches.json +# edit both switches to driver_mode="mock" if needed +build/bin/sweep_orchestrator --config /tmp/librevna_mock_switches.json +``` + +## LibreVNA Multi-Device + +Use this mode for one master LibreVNA and two slave LibreVNAs. This mode does +not use physical RF switch GPIO in the acquisition producer. It exposes a fixed +virtual matrix: + +```text +inputs: 0..3 +outputs: 0..1 +combos: 8 +``` + +Config: + +```json +"radar": { + "model": "librevna_multi", + "serial": "MASTER_SERIAL", + "driver_mode": "native", + "multi_device": { + "slave_serials": [ + "SLAVE_SERIAL_1", + "SLAVE_SERIAL_2" + ], + "force_external_reference": true, + "recovery_attempts": 3 + } +} +``` + +Notes: + +- Exactly two slave serials are required. +- `force_external_reference` configures the synchronized reference workflow. +- `recovery_attempts` controls reopen/retry attempts after native acquisition + errors. +- The Python producer is selected automatically by the GUI. Manual raw-producer + run: + +```bash +.venv/bin/python -m python_app.scripts.multi_device_raw_producer \ + --config run_config_librevna_multi.example.json +``` + +## Compact-M K209 On The Same Computer + +Use this for local development on the x86_64 computer that runs S2VNA and has +the K209 connected over USB-C. GPIO can be disabled with mock switches. + +1. Start S2VNA and enable HiSLIP on port `4880`. + +2. Start the local project K209 server: + +```bash +.venv/bin/python -m python_app.scripts.k209_remote_server \ + --host 127.0.0.1 \ + --port 50209 +``` + +3. In another terminal, smoke-test the server: + +```bash +.venv/bin/python -m python_app.scripts.k209_remote_smoke_test \ + --host 127.0.0.1 \ + --port 50209 +``` + +4. Run one acquisition with mock switches: + +```bash +build/bin/sweep_orchestrator \ + --config run_config_compact_m_k209_local_mock_switches.example.json +``` + +This mode is useful on a laptop because it avoids GPIO dependencies. + +## Compact-M K209 With Raspberry Pi GPIO + +Use this for the real K209 + Raspberry Pi setup: + +```text +K209 --USB-C--> x86_64 computer running S2VNA +x86_64 computer --Ethernet--> Raspberry Pi 5 +Raspberry Pi 5 --GPIO--> RF switches +``` + +On the x86_64 computer: + +```bash +cd /path/to/radar_system +.venv/bin/python -m python_app.scripts.k209_remote_server \ + --host 0.0.0.0 \ + --port 50209 +``` + +On the Raspberry Pi, set `radar.remote_host` to the Ethernet IP address of the +x86_64 computer: + +```json +"radar": { + "model": "compact_m_k209", + "remote_host": "192.168.1.10", + "remote_port": 50209, + "driver_mode": "native" +} +``` + +Then run the GUI or producer on the Raspberry Pi: + +```bash +.venv/bin/python -m python_app.gui.main +``` + +For a command-line connection check from the Raspberry Pi: + +```bash +.venv/bin/python -m python_app.scripts.k209_remote_smoke_test \ + --host 192.168.1.10 \ + --port 50209 +``` + +The Raspberry Pi does not need S2VNA or NI-VISA in this remote mode. + +## K209 Remote Performance + +The remote K209 path keeps one persistent TCP connection open. Configuration +sends sweep settings once and receives the frequency axis once. Each sweep then +sends one command byte and receives only binary `S11` and `S21` `float32` +arrays. + +Use wired Ethernet. Wi-Fi works for tests but adds jitter. + diff --git a/docs/run_config.md b/docs/run_config.md new file mode 100644 index 0000000..0dc2855 --- /dev/null +++ b/docs/run_config.md @@ -0,0 +1,324 @@ +# Run Config Reference + +`run_config.json` is the stable runtime configuration consumed by the GUI, +Python helpers, and C++ pipeline binaries. The active file is normally +`run_config.json`; root-level `*.example.json` files are templates. + +JSON does not support comments. Keep notes in docs, not inside config files. + +## Top-Level Sections + +```json +{ + "radar": {}, + "switches": {}, + "run": {}, + "preprocess": {}, + "gpr": {}, + "rings": {} +} +``` + +## `radar` + +Selects the radar model and sweep settings. + +```json +"radar": { + "model": "compact_m_k209", + "serial": "", + "remote_host": "127.0.0.1", + "remote_port": 50209, + "driver_mode": "native", + "mock_signal_hz": 5000000.0, + "multi_device": {}, + "sweep": {} +} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `model` | `librevna`, `librevna_multi`, or `compact_m_k209`. | +| `serial` | LibreVNA serial. Empty means first device for single LibreVNA. For `librevna_multi`, this is the master serial. | +| `remote_host` | K209 remote server host. Used by `compact_m_k209`; ignored by LibreVNA modes. | +| `remote_port` | K209 remote server TCP port. Default is `50209`. | +| `driver_mode` | `native` for hardware, `mock` for supported synthetic LibreVNA modes. K209 requires `native`. | +| `mock_signal_hz` | Existing LibreVNA mock signal parameter used by C++ mock acquisition. | +| `multi_device` | Extra settings for `librevna_multi`. | +| `sweep` | Frequency, point count, IFBW, and power settings. | + +### `radar.sweep` + +```json +"sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 +} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `start_hz` | Sweep start frequency in Hz. | +| `stop_hz` | Sweep stop frequency in Hz. Must be `>= start_hz`. | +| `points` | Number of frequency points. | +| `if_bandwidth_hz` | IF bandwidth in Hz. | +| `stimulus_power_dbm` | Output power in dBm. | + +K209 limits reported by the tested device: + +```text +frequency_hz: 9000 .. 9000000000 +ifbw_hz: 1 .. 300000 +power_dbm: -55 .. +5 +points: 2 .. 500001 +``` + +### `radar.multi_device` + +Used only when `radar.model == "librevna_multi"`. + +```json +"multi_device": { + "slave_serials": [ + "SLAVE_SERIAL_1", + "SLAVE_SERIAL_2" + ], + "force_external_reference": true, + "recovery_attempts": 3 +} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `slave_serials` | Exactly two slave LibreVNA serials. | +| `force_external_reference` | Configure the synchronized external reference path. | +| `recovery_attempts` | Reopen/retry attempts after native multi-device acquisition errors. | + +## `switches` + +Two RF switch sections are used: + +```json +"switches": { + "port1": {}, + "port2": {} +} +``` + +By convention in the C++ pipeline: + +```text +port1 -> output switch +port2 -> input switch +``` + +Switch fields: + +| Field | Meaning | +| --- | --- | +| `name` | Human-readable switch name. | +| `driver_mode` | `native` for GPIO, `mock` to avoid GPIO access. | +| `driver` | `h7992` or `hmc349a`. | +| `radar_port` | Physical radar port mapping, must be unique and either `1` or `2`. | +| `positions` | Number of switch positions. | +| `default_position` | Position selected on open. Zero-based. | +| `gpio_chip` | Linux GPIO chip path, usually `/dev/gpiochip0`. | +| `pin_a` | First GPIO control pin. | +| `pin_b` | Second GPIO control pin for `h7992`. | +| `invert_logic` | Logic inversion for supported switch drivers. | + +Use mock switches on a laptop without GPIO: + +```json +"driver_mode": "mock" +``` + +## `run` + +Runtime behavior and combo selection. + +```json +"run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": true, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "locator_server": {}, + "combos": [ + {"input": 0, "output": 0} + ] +} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `settling_ms` | Delay after switching before measuring. | +| `idle_sleep_ms` | Sleep between continuous collections. | +| `continuous` | `true` loops until stopped; `false` captures one collection and exits. | +| `processing_live_config_path` | Runtime path used by processing live settings. | +| `locator_server` | Embedded TCP server settings for publishing locator results. | +| `combos` | Zero-based switch combinations to acquire. | + +`combos` entries use input/output switch positions: + +```json +{"input": 2, "output": 1} +``` + +For `librevna_multi`, the model constraints force the canonical virtual matrix: + +```text +input: 0..3 +output: 0..1 +``` + +## `run.locator_server` + +Settings for the embedded locator result TCP server. + +| Field | Meaning | +| --- | --- | +| `device_id` | Device identifier in locator payloads. | +| `protocol_version` | Locator payload protocol version. | +| `host` | Bind host, commonly `0.0.0.0`. | +| `port` | TCP port, commonly `8888`. | +| `max_payload_bytes` | Maximum result payload size. | +| `client_queue_size` | Per-client queue size. | +| `logger_name` | Logger name used by the service. | + +## `preprocess` + +Names or bundle paths for calibration/reference assets used by preprocessing. + +```json +"preprocess": { + "s21": { + "calibration": {"set_name": "", "bundle_path": ""}, + "reference": {"set_name": "", "bundle_path": ""} + }, + "s11": { + "calibration": { + "open": {"set_name": "", "bundle_path": ""}, + "short": {"set_name": "", "bundle_path": ""}, + "load": {"set_name": "", "bundle_path": ""} + }, + "reference": {"set_name": "", "bundle_path": ""} + }, + "notch": { + "enabled": true, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } +} +``` + +`set_name` selects a stored set for the active radar key. `bundle_path` can +point to an exported bundle. Empty values mean no asset is selected. + +`notch.bands_hz` is a list of `[low_hz, high_hz]` ranges. `taper_type` is +`cosine` or `hard`. + +## `gpr` + +GPR geometry and processing configuration. + +```json +"gpr": { + "mode": "point", + "relative_permittivity": 1.0, + "tx_geometry": [ + {"output_pos": 0, "x_m": 0.905} + ], + "rx_geometry": [ + {"input_pos": 0, "x_m": -0.18} + ] +} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `mode` | GPR processing mode. | +| `relative_permittivity` | Medium relative permittivity used for propagation speed. | +| `tx_geometry` | Transmitter positions keyed by output switch position. | +| `rx_geometry` | Receiver positions keyed by input switch position. | + +Geometry positions must match configured switch positions. For example, an +`output_pos` of `1` requires the output switch to have at least 2 positions. + +## `rings` + +Shared-memory ring endpoints used by native processes. + +```json +"rings": { + "raw": {"name": "/radar_raw", "capacity": 50, "slot_size_bytes": 2097152}, + "raw_tap": {"name": "/radar_raw_tap", "capacity": 50, "slot_size_bytes": 2097152}, + "preprocessed": {"name": "/radar_preprocessed", "capacity": 50, "slot_size_bytes": 2097152}, + "preprocessed_tap": {"name": "/radar_preprocessed_tap", "capacity": 50, "slot_size_bytes": 2097152}, + "results": {"name": "/radar_results", "capacity": 50, "slot_size_bytes": 2097152} +} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `name` | POSIX shared-memory object name. | +| `capacity` | Number of slots. | +| `slot_size_bytes` | Maximum serialized payload size per slot. | + +Use unique ring names for parallel tests to avoid collisions with a running GUI +session. + +## Minimal Model Examples + +Single LibreVNA: + +```json +"radar": { + "model": "librevna", + "serial": "", + "driver_mode": "native" +} +``` + +Multi-device LibreVNA: + +```json +"radar": { + "model": "librevna_multi", + "serial": "MASTER_SERIAL", + "driver_mode": "native", + "multi_device": { + "slave_serials": ["SLAVE_1", "SLAVE_2"], + "force_external_reference": true, + "recovery_attempts": 3 + } +} +``` + +Compact-M K209 via remote server: + +```json +"radar": { + "model": "compact_m_k209", + "remote_host": "192.168.1.10", + "remote_port": 50209, + "driver_mode": "native" +} +``` + diff --git a/python_app/gui/controllers/app_window_config/radar_limits_mixin.py b/python_app/gui/controllers/app_window_config/radar_limits_mixin.py index c470e76..7cb61b0 100644 --- a/python_app/gui/controllers/app_window_config/radar_limits_mixin.py +++ b/python_app/gui/controllers/app_window_config/radar_limits_mixin.py @@ -3,10 +3,11 @@ from __future__ import annotations from python_app.hardware_full.librevna_service import LibreVnaService +from python_app.hardware_full.single_radar_service import create_single_radar_service class AppWindowRadarLimitsMixin: - """Handle LibreVNA capability probing and dependent UI clamping.""" + """Handle radar capability probing and dependent UI clamping.""" def _on_radar_sweep_limits_changed(self) -> None: """Clamp processing frequency bounds after sweep start/stop edits.""" @@ -15,27 +16,19 @@ class AppWindowRadarLimitsMixin: self._on_processing_live_settings_changed() def _refresh_radar_limits_from_device(self) -> bool: - """Query native LibreVNA limits and apply them to GUI fields.""" - serial = self._defaults_config.radar.serial - radar_service = LibreVnaService(serial=serial or None) - if not radar_service.driver_available: - self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query") - return False + """Query native radar limits and apply them to GUI fields.""" + config = self._defaults_config + if config.is_multi_device: + radar_service = LibreVnaService(serial=config.radar.serial or None) + else: + radar_service = create_single_radar_service(config) - try: - limits = radar_service.read_device_limits() - except Exception as exc: # noqa: BLE001 - self._log_exception("Failed to query LibreVNA limits; using UI fallback", exc, level="WARN") - self._apply_radar_limits_to_ui(None) - return False + if isinstance(radar_service, LibreVnaService) and not radar_service.driver_available: + raise RuntimeError("LibreVNA Python driver is not available for device limits query") + limits = radar_service.read_device_limits() return self._apply_radar_limits_to_ui(limits) - def _fallback_to_mock_mode(self, reason: str) -> None: - """Handle unavailable native limits without mutating JSON-backed mode.""" - self._log_warning(reason) - self._apply_radar_limits_to_ui(None) - def _apply_radar_limits_to_ui(self, limits: dict[str, float | int] | None) -> bool: """Apply optional radar limits and clamp dependent GUI fields.""" previous_limits = dict(self._radar_limits) if self._radar_limits is not None else None diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index a835fcb..089516a 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -6,7 +6,7 @@ import time from python_app.gui.runtime.constraints import validate_processing_mode_constraints from python_app.gui.runtime.history import build_run_history_signature, record_result_history -from python_app.hardware_full.librevna_service import LibreVnaService +from python_app.hardware_full.single_radar_service import create_single_radar_service from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection from python_app.models.run_config_model import RunConfigModel from python_app.orchestration.gpr_locator import collection_has_gpr_payloads @@ -181,7 +181,9 @@ class AppWindowPipelineMixin: self._start_run() def _prepare_radar_for_native_acquisition(self, config: RunConfigModel) -> None: - """Preconfigure native LibreVNA using current sweep settings.""" + """Preconfigure native single-radar hardware using current sweep settings.""" + if config.radar.model == RunConfigModel.COMPACT_M_K209_MODEL and config.radar.driver_mode != "native": + raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'") if config.radar.driver_mode != "native": self._log("Radar pre-configuration skipped (mock mode)") return @@ -189,8 +191,8 @@ class AppWindowPipelineMixin: self._log("Multi-device raw producer will configure all LibreVNA devices") return - radar_service = LibreVnaService(serial=config.radar.serial or None) - if not radar_service.driver_available: + radar_service = create_single_radar_service(config) + if not getattr(radar_service, "driver_available", True): raise RuntimeError("LibreVNA Python driver is not available for native pre-configuration") try: @@ -199,7 +201,7 @@ class AppWindowPipelineMixin: finally: radar_service.close() - self._log("Radar pre-configured via Python driver") + self._log(f"Radar pre-configured via Python driver: model={config.radar.model}") def _stop_run(self) -> None: """Stop acquisition-side processes and close readers as needed.""" diff --git a/python_app/hardware_full/compact_m_k209_service.py b/python_app/hardware_full/compact_m_k209_service.py index 39e4a5d..08d65f2 100644 --- a/python_app/hardware_full/compact_m_k209_service.py +++ b/python_app/hardware_full/compact_m_k209_service.py @@ -110,16 +110,29 @@ class CompactMK209Service: 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?")), - } + opened_here = self._instrument is None + try: + if opened_here: + self.open() + 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?")), + } + finally: + if opened_here: + self.close() + + def frequency_axis(self) -> np.ndarray: + """Return the configured frequency axis.""" + if self._frequency_hz is None: + raise RuntimeError("K209 frequency axis is not configured") + return self._frequency_hz def acquire_interleaved(self) -> CompactMK209InterleavedSweep: """Acquire one corrected sweep without converting interleaved arrays.""" diff --git a/python_app/hardware_full/k209_remote_protocol.py b/python_app/hardware_full/k209_remote_protocol.py new file mode 100644 index 0000000..34db875 --- /dev/null +++ b/python_app/hardware_full/k209_remote_protocol.py @@ -0,0 +1,90 @@ +"""Binary TCP protocol shared by the K209 remote server and Python client.""" + +from __future__ import annotations + +import socket +import struct +from typing import BinaryIO + +import numpy as np + +COMMAND_IDENTITY = b"I" +COMMAND_LIMITS = b"L" +COMMAND_CONFIGURE = b"C" +COMMAND_ACQUIRE = b"A" + +STATUS_OK = b"O" +STATUS_ERROR = b"E" + +DEFAULT_REMOTE_HOST = "127.0.0.1" +DEFAULT_REMOTE_PORT = 50209 + +CONFIG_STRUCT = struct.Struct("!ddIdd") +LIMITS_STRUCT = struct.Struct("!ddddIdd") +U32_STRUCT = struct.Struct("!I") + + +def recv_exact(stream: socket.socket | BinaryIO, size: int) -> bytes: + """Read exactly `size` bytes from a socket-like object.""" + chunks: list[bytes] = [] + remaining = int(size) + while remaining > 0: + chunk = stream.recv(remaining) if isinstance(stream, socket.socket) else stream.read(remaining) + if not chunk: + raise ConnectionError(f"K209 remote connection closed with {remaining} bytes pending") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def send_all(stream: socket.socket | BinaryIO, payload: bytes) -> None: + """Write all payload bytes to a socket-like object.""" + if isinstance(stream, socket.socket): + stream.sendall(payload) + return + stream.write(payload) + + +def send_u32(stream: socket.socket | BinaryIO, value: int) -> None: + """Send one network-order uint32.""" + send_all(stream, U32_STRUCT.pack(int(value))) + + +def recv_u32(stream: socket.socket | BinaryIO) -> int: + """Read one network-order uint32.""" + return int(U32_STRUCT.unpack(recv_exact(stream, U32_STRUCT.size))[0]) + + +def send_error(stream: socket.socket | BinaryIO, message: str) -> None: + """Send protocol error response.""" + payload = str(message).encode("utf-8", errors="replace") + send_all(stream, STATUS_ERROR) + send_u32(stream, len(payload)) + send_all(stream, payload) + + +def read_status(stream: socket.socket | BinaryIO) -> None: + """Read response status and raise remote error when needed.""" + status = recv_exact(stream, 1) + if status == STATUS_OK: + return + if status == STATUS_ERROR: + message = recv_exact(stream, recv_u32(stream)).decode("utf-8", errors="replace") + raise RuntimeError(f"K209 remote server error: {message}") + raise RuntimeError(f"K209 remote server returned invalid status byte: {status!r}") + + +def send_float32_array(stream: socket.socket | BinaryIO, values: np.ndarray) -> None: + """Send a float32 array as a length-prefixed little-endian payload.""" + payload = np.asarray(values, dtype=" np.ndarray: + """Read a length-prefixed little-endian float32 array.""" + payload_size = recv_u32(stream) + expected_size = int(expected_values) * np.dtype(np.float32).itemsize + if payload_size != expected_size: + raise RuntimeError(f"K209 remote payload has {payload_size} bytes, expected {expected_size}") + return np.frombuffer(recv_exact(stream, payload_size), dtype=" None: + self.host = str(self.host).strip() + if not self.host: + raise ValueError("K209 remote host must not be empty") + self.port = int(self.port) + if self.port <= 0 or self.port > 65535: + raise ValueError("K209 remote port must be in 1..65535") + self.timeout_s = float(self.timeout_s) + if self.timeout_s <= 0.0: + raise ValueError("K209 remote timeout_s must be > 0") + + def open(self) -> None: + """Open TCP connection and apply stored settings when present.""" + if self._socket is not None: + return + sock = socket.create_connection((self.host, self.port), timeout=self.timeout_s) + sock.settimeout(self.timeout_s) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self._socket = sock + try: + if self._settings is not None: + self._apply_configuration(self._settings) + except Exception: + self.close() + raise + + def close(self) -> None: + """Close TCP connection.""" + if self._socket is None: + return + try: + self._socket.close() + finally: + self._socket = None + + def query_identity(self) -> str: + """Read analyzer identity string through the remote server.""" + sock = self._require_socket() + sock.sendall(COMMAND_IDENTITY) + read_status(sock) + return recv_exact(sock, recv_u32(sock)).decode("utf-8", errors="replace").strip() + + def read_device_limits(self) -> dict[str, float | int]: + """Read analyzer limits through the remote server.""" + opened_here = self._socket is None + try: + if opened_here: + self.open() + sock = self._require_socket() + sock.sendall(COMMAND_LIMITS) + read_status(sock) + min_freq, max_freq, min_ifbw, max_ifbw, max_points, min_power, max_power = LIMITS_STRUCT.unpack( + recv_exact(sock, LIMITS_STRUCT.size) + ) + return { + "min_frequency_hz": float(min_freq), + "max_frequency_hz": float(max_freq), + "min_ifbw_hz": float(min_ifbw), + "max_ifbw_hz": float(max_ifbw), + "max_points": int(max_points), + "min_power_dbm": float(min_power), + "max_power_dbm": float(max_power), + } + finally: + if opened_here: + self.close() + + def configure(self, sweep: RadarSweepModel) -> None: + """Store and apply sweep settings.""" + self._validate_sweep(sweep) + self._settings = sweep + self._frequency_hz = None + if self._socket is not None: + self._apply_configuration(sweep) + + def acquire(self) -> SweepResult: + """Acquire one corrected S11/S21 sweep.""" + if self._settings is None or self._frequency_hz is None: + raise RuntimeError("K209 remote service is not configured") + sock = self._require_socket() + points = int(self._settings.points) + sock.sendall(COMMAND_ACQUIRE) + read_status(sock) + returned_points = recv_u32(sock) + if returned_points != points: + raise RuntimeError(f"K209 remote sweep returned {returned_points} points, expected {points}") + s11_values = recv_float32_array(sock, points * 2) + s21_values = recv_float32_array(sock, points * 2) + return SweepResult( + x=self._frequency_hz.copy(), + traces={ + "s11": self._complex_from_interleaved(s11_values), + "s21": self._complex_from_interleaved(s21_values), + }, + ) + + def _apply_configuration(self, sweep: RadarSweepModel) -> None: + sock = self._require_socket() + sock.sendall(COMMAND_CONFIGURE) + sock.sendall( + CONFIG_STRUCT.pack( + float(sweep.start_hz), + float(sweep.stop_hz), + int(sweep.points), + float(sweep.if_bandwidth_hz), + float(sweep.power_dbm), + ) + ) + read_status(sock) + returned_points = recv_u32(sock) + if returned_points != int(sweep.points): + raise RuntimeError(f"K209 remote config returned {returned_points} points, expected {sweep.points}") + self._frequency_hz = recv_float32_array(sock, int(sweep.points)) + + def _require_socket(self) -> socket.socket: + if self._socket is None: + raise RuntimeError("K209 remote socket is not open") + return self._socket + + @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 remote 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/hardware_full/single_radar_service.py b/python_app/hardware_full/single_radar_service.py new file mode 100644 index 0000000..a2aa2c3 --- /dev/null +++ b/python_app/hardware_full/single_radar_service.py @@ -0,0 +1,49 @@ +"""Factory for single-radar Python acquisition services.""" + +from __future__ import annotations + +from typing import Protocol + +from python_app.hardware_full.librevna_driver.models import SweepResult +from python_app.hardware_full.librevna_service import LibreVnaService +from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service +from python_app.models.run_config_model import RadarSweepModel, RunConfigModel + + +class SingleRadarService(Protocol): + """Common API used by single-radar workflows.""" + + def open(self) -> None: + """Open radar connection.""" + + def close(self) -> None: + """Close radar connection.""" + + def configure(self, sweep: RadarSweepModel) -> None: + """Apply sweep settings.""" + + def read_device_limits(self) -> dict[str, float | int]: + """Read device capability limits.""" + + def acquire(self) -> SweepResult: + """Acquire one sweep.""" + + +def create_single_radar_service(config: RunConfigModel) -> SingleRadarService: + """Create the Python service for a non-multi-device radar config.""" + if config.is_multi_device: + raise RuntimeError("single-radar service factory does not support librevna_multi") + + model = config.radar.model or RunConfigModel.LIBREVNA_MODEL + if model == RunConfigModel.LIBREVNA_MODEL: + return LibreVnaService(serial=config.radar.serial or None) + + if model == RunConfigModel.COMPACT_M_K209_MODEL: + if config.radar.driver_mode != "native": + raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'") + return RemoteCompactMK209Service( + host=config.radar.remote_host, + port=config.radar.remote_port, + ) + + raise RuntimeError(f"Unsupported single-radar model: {model}") diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 94e427b..641526d 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -57,6 +57,8 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: model.radar.model = str(radar_payload.get("model", model.radar.model)) model.radar.serial = str(radar_payload.get("serial", model.radar.serial)) + model.radar.remote_host = str(radar_payload.get("remote_host", model.radar.remote_host)) + model.radar.remote_port = int(radar_payload.get("remote_port", model.radar.remote_port)) model.radar.driver_mode = str(radar_payload.get("driver_mode", model.radar.driver_mode)) model.radar.mock_signal_hz = float(radar_payload.get("mock_signal_hz", model.radar.mock_signal_hz)) @@ -239,6 +241,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: "radar": { "model": model.radar.model, "serial": model.radar.serial, + "remote_host": model.radar.remote_host, + "remote_port": model.radar.remote_port, "driver_mode": model.radar.driver_mode, "mock_signal_hz": model.radar.mock_signal_hz, "multi_device": { diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 5347cd2..07ab8ae 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -43,6 +43,8 @@ class RadarModel: model: str = "librevna" serial: str = "" + remote_host: str = "127.0.0.1" + remote_port: int = 50209 driver_mode: str = "mock" mock_signal_hz: float = 1_000_000.0 sweep: RadarSweepModel = field(default_factory=RadarSweepModel) diff --git a/python_app/scripts/hardware_raw_orchestrator_test.py b/python_app/scripts/hardware_raw_orchestrator_test.py index 626dfa1..d2ffe60 100644 --- a/python_app/scripts/hardware_raw_orchestrator_test.py +++ b/python_app/scripts/hardware_raw_orchestrator_test.py @@ -22,8 +22,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from python_app.hardware_full.librevna_service import LibreVnaService -from python_app.models.run_config_model import RadarSweepModel +from python_app.hardware_full.single_radar_service import create_single_radar_service +from python_app.models.run_config_model import RunConfigModel from python_app.orchestration.shm_reader import ShmRingReader @@ -71,22 +71,15 @@ def _read_native_summary(config_path: Path) -> str: def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None: """Preconfigure native radar through Python service when requested.""" - config = json.loads(config_path.read_text(encoding="utf-8")) - radar = config["radar"] - if radar["driver_mode"] != "native": + config_payload = json.loads(config_path.read_text(encoding="utf-8")) + config = RunConfigModel.from_dict(config_payload) + if config.radar.driver_mode != "native": return "Radar pre-configuration skipped (mock mode)." + if config.is_multi_device: + return "Radar pre-configuration skipped (multi-device producer config)." - sweep = radar["sweep"] - sweep_model = RadarSweepModel( - start_hz=float(sweep["start_hz"]), - stop_hz=float(sweep["stop_hz"]), - points=int(sweep["points"]), - if_bandwidth_hz=float(sweep["if_bandwidth_hz"]), - power_dbm=float(sweep.get("stimulus_power_dbm", -10.0)), - ) - - radar_service = LibreVnaService(serial=radar.get("serial") or None) - if not radar_service.driver_available: + radar_service = create_single_radar_service(config) + if not getattr(radar_service, "driver_available", True): message = "LibreVNA Python driver is unavailable: skipping pre-configuration" if strict: raise RuntimeError(message) @@ -94,7 +87,7 @@ def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None: try: radar_service.open() - radar_service.configure(sweep_model) + radar_service.configure(config.radar.sweep) return "Radar pre-configuration completed." except Exception as exc: message = f"Radar pre-configuration failed ({exc})" diff --git a/python_app/scripts/k209_remote_server.py b/python_app/scripts/k209_remote_server.py new file mode 100644 index 0000000..a1659d5 --- /dev/null +++ b/python_app/scripts/k209_remote_server.py @@ -0,0 +1,151 @@ +"""Run a TCP acquisition server for a locally connected Compact-M K209.""" + +from __future__ import annotations + +import argparse +import socket +import socketserver +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from python_app.hardware_full.compact_m_k209_service import CompactMK209Service +from python_app.hardware_full.k209_remote_protocol import ( + COMMAND_ACQUIRE, + COMMAND_CONFIGURE, + COMMAND_IDENTITY, + COMMAND_LIMITS, + CONFIG_STRUCT, + DEFAULT_REMOTE_PORT, + LIMITS_STRUCT, + STATUS_OK, + recv_exact, + send_error, + send_float32_array, + send_u32, +) +from python_app.models.run_config_model import RadarSweepModel + +DEFAULT_RESOURCE = "TCPIP0::127.0.0.1::hislip0,4880::INSTR" + + +class K209RemoteRequestHandler(socketserver.StreamRequestHandler): + """Handle one persistent K209 remote client connection.""" + + def setup(self) -> None: + super().setup() + self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.service = CompactMK209Service( + resource=self.server.resource, + timeout_ms=self.server.timeout_ms, + preset_on_open=False, + visa_library="@ivi", + ) + self.service.open() + + def finish(self) -> None: + try: + self.service.close() + finally: + super().finish() + + def handle(self) -> None: + while True: + command = self.rfile.read(1) + if not command: + return + try: + if command == COMMAND_IDENTITY: + self._handle_identity() + elif command == COMMAND_LIMITS: + self._handle_limits() + elif command == COMMAND_CONFIGURE: + self._handle_configure() + elif command == COMMAND_ACQUIRE: + self._handle_acquire() + else: + raise RuntimeError(f"unsupported command byte {command!r}") + self.wfile.flush() + except Exception as exc: # noqa: BLE001 + send_error(self.wfile, str(exc)) + self.wfile.flush() + + def _handle_identity(self) -> None: + payload = self.service.query_identity().encode("utf-8") + self.wfile.write(STATUS_OK) + send_u32(self.wfile, len(payload)) + self.wfile.write(payload) + + def _handle_limits(self) -> None: + limits = self.service.read_device_limits() + self.wfile.write(STATUS_OK) + self.wfile.write( + LIMITS_STRUCT.pack( + float(limits["min_frequency_hz"]), + float(limits["max_frequency_hz"]), + float(limits["min_ifbw_hz"]), + float(limits["max_ifbw_hz"]), + int(limits["max_points"]), + float(limits["min_power_dbm"]), + float(limits["max_power_dbm"]), + ) + ) + + def _handle_configure(self) -> None: + start_hz, stop_hz, points, ifbw_hz, power_dbm = CONFIG_STRUCT.unpack( + recv_exact(self.rfile, CONFIG_STRUCT.size) + ) + sweep = RadarSweepModel( + start_hz=start_hz, + stop_hz=stop_hz, + points=int(points), + if_bandwidth_hz=ifbw_hz, + power_dbm=power_dbm, + ) + self.service.configure(sweep) + self.wfile.write(STATUS_OK) + send_u32(self.wfile, int(points)) + send_float32_array(self.wfile, self.service.frequency_axis()) + + def _handle_acquire(self) -> None: + sweep = self.service.acquire_interleaved() + self.wfile.write(STATUS_OK) + send_u32(self.wfile, int(sweep.frequency_hz.size)) + send_float32_array(self.wfile, sweep.s11_values) + send_float32_array(self.wfile, sweep.s21_values) + + +class K209RemoteServer(socketserver.TCPServer): + """Single-client TCP server with K209 connection settings.""" + + allow_reuse_address = True + + def __init__(self, server_address: tuple[str, int], resource: str, timeout_ms: int) -> None: + self.resource = resource + self.timeout_ms = timeout_ms + super().__init__(server_address, K209RemoteRequestHandler) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Serve a locally connected Compact-M K209 over TCP.") + parser.add_argument("--host", default="0.0.0.0", help="Server bind address.") + parser.add_argument("--port", type=int, default=DEFAULT_REMOTE_PORT, help="Server TCP port.") + parser.add_argument("--resource", default=DEFAULT_RESOURCE, help="Local S2VNA VISA resource.") + parser.add_argument("--timeout-ms", type=int, default=20_000, help="K209 VISA timeout.") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + with K209RemoteServer((args.host, args.port), resource=args.resource, timeout_ms=args.timeout_ms) as server: + print(f"K209 remote server listening on {args.host}:{args.port}") + print(f"Local S2VNA resource: {args.resource}") + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python_app/scripts/k209_remote_smoke_test.py b/python_app/scripts/k209_remote_smoke_test.py new file mode 100644 index 0000000..ba52a98 --- /dev/null +++ b/python_app/scripts/k209_remote_smoke_test.py @@ -0,0 +1,69 @@ +"""Smoke test for a remote Compact-M K209 server.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from python_app.hardware_full.k209_remote_protocol import DEFAULT_REMOTE_HOST, DEFAULT_REMOTE_PORT +from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service +from python_app.models.run_config_model import RadarSweepModel + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate remote K209 connection and one sweep.") + parser.add_argument("--host", default=DEFAULT_REMOTE_HOST, help="K209 remote server host.") + parser.add_argument("--port", type=int, default=DEFAULT_REMOTE_PORT, help="K209 remote server port.") + parser.add_argument("--start-hz", type=float, default=10_000_000.0) + parser.add_argument("--stop-hz", type=float, default=100_000_000.0) + parser.add_argument("--points", type=int, default=11) + parser.add_argument("--ifbw-hz", type=float, default=10_000.0) + parser.add_argument("--power-dbm", type=float, default=-20.0) + return parser.parse_args() + + +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 = RemoteCompactMK209Service(host=args.host, port=args.port) + try: + service.open() + print(f"K209 IDN: {service.query_identity()}") + service.configure(sweep) + result = service.acquire() + finally: + service.close() + + s11 = result.trace("s11") + s21 = result.trace("s21") + if result.x.size != args.points or s11.size != args.points or s21.size != args.points: + raise RuntimeError("Remote K209 sweep returned an unexpected point count") + if not np.all(np.isfinite(result.x)) or not np.all(np.isfinite(s11)) or not np.all(np.isfinite(s21)): + raise RuntimeError("Remote K209 sweep contains non-finite values") + if not np.all(np.diff(result.x) >= 0): + raise RuntimeError("Remote K209 frequency axis is not monotonic") + + print( + "Remote K209 sweep OK: " + f"points={args.points}, first_hz={result.x[0]:.3f}, last_hz={result.x[-1]:.3f}, " + f"mean_abs_s11={float(np.mean(np.abs(s11))):.6g}, " + f"mean_abs_s21={float(np.mean(np.abs(s21))):.6g}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python_app/workflows/calibration_workflow.py b/python_app/workflows/calibration_workflow.py index 0c0f707..f523beb 100644 --- a/python_app/workflows/calibration_workflow.py +++ b/python_app/workflows/calibration_workflow.py @@ -4,7 +4,7 @@ from __future__ import annotations import time -from python_app.hardware_full.librevna_service import LibreVnaService +from python_app.hardware_full.single_radar_service import create_single_radar_service 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 RunConfigModel @@ -26,7 +26,7 @@ def capture_calibration_set( combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) - radar = LibreVnaService(serial=config.radar.serial or None) + radar = create_single_radar_service(config) input_switch = SwitchService( name=config.input_switch.name, positions=config.input_switch.positions, diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 2a4d9d8..ffeae75 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -8,8 +8,8 @@ import time import numpy as np -from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService +from python_app.hardware_full.single_radar_service import create_single_radar_service 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 ComboModel, RunConfigModel @@ -100,7 +100,7 @@ class MultiRadarSequentialCaptureSession: self._input_switch = None self._output_switch = None else: - self._radar = LibreVnaService(serial=base_config.radar.serial or None) + self._radar = create_single_radar_service(base_config) self._input_switch = SwitchService( name=base_config.input_switch.name, positions=base_config.input_switch.positions, diff --git a/python_app/workflows/reference_workflow.py b/python_app/workflows/reference_workflow.py index dcd7e42..24a002f 100644 --- a/python_app/workflows/reference_workflow.py +++ b/python_app/workflows/reference_workflow.py @@ -4,8 +4,8 @@ from __future__ import annotations import time -from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService +from python_app.hardware_full.single_radar_service import create_single_radar_service 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 RunConfigModel @@ -48,7 +48,7 @@ def capture_reference_set( combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) - radar = LibreVnaService(serial=config.radar.serial or None) + radar = create_single_radar_service(config) input_switch = SwitchService( name=config.input_switch.name, positions=config.input_switch.positions, diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 64d1792..3f17ba8 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -8,8 +8,8 @@ import time import numpy as np -from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService +from python_app.hardware_full.single_radar_service import create_single_radar_service 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 ComboModel, RunConfigModel @@ -70,7 +70,7 @@ class SequentialCaptureSession: self._input_switch = None self._output_switch = None else: - self._radar = LibreVnaService(serial=config.radar.serial or None) + self._radar = create_single_radar_service(config) self._input_switch = SwitchService( name=config.input_switch.name, positions=config.input_switch.positions, diff --git a/run_config.json b/run_config.json index b763f27..f168c8b 100644 --- a/run_config.json +++ b/run_config.json @@ -1,15 +1,14 @@ { "radar": { - "model": "librevna_multi", - "serial": "207730885532", + "model": "compact_m_k209", + "serial": "", + "remote_host": "127.0.0.1", + "remote_port": 50209, "driver_mode": "native", "mock_signal_hz": 5000000.0, "multi_device": { - "slave_serials": [ - "20A1307D5532", - "2072306C5532" - ], - "force_external_reference": true, + "slave_serials": [], + "force_external_reference": false, "recovery_attempts": 3 }, "sweep": { @@ -23,10 +22,10 @@ "switches": { "port1": { "name": "port1", - "driver_mode": "native", + "driver_mode": "mock", "driver": "h7992", "radar_port": 1, - "positions": 4, + "positions": 2, "default_position": 0, "gpio_chip": "/dev/gpiochip0", "pin_a": 17, @@ -35,7 +34,7 @@ }, "port2": { "name": "port2", - "driver_mode": "native", + "driver_mode": "mock", "driver": "h7992", "radar_port": 2, "positions": 4, @@ -49,7 +48,7 @@ "run": { "settling_ms": 0, "idle_sleep_ms": 2, - "continuous": true, + "continuous": false, "processing_live_config_path": "python_app/runtime/processing_live.json", "locator_server": { "device_id": 3, @@ -61,14 +60,38 @@ "logger_name": "locator_runtime" }, "combos": [ - {"input": 0, "output": 0}, - {"input": 1, "output": 0}, - {"input": 2, "output": 0}, - {"input": 3, "output": 0}, - {"input": 0, "output": 1}, - {"input": 1, "output": 1}, - {"input": 2, "output": 1}, - {"input": 3, "output": 1} + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 1 + }, + { + "input": 1, + "output": 1 + }, + { + "input": 2, + "output": 1 + }, + { + "input": 3, + "output": 1 + } ] }, "preprocess": { @@ -104,8 +127,7 @@ }, "notch": { "enabled": true, - "bands_hz": [ - ], + "bands_hz": [], "taper_width_hz": 40000000.0, "taper_type": "cosine" } @@ -144,27 +166,27 @@ }, "rings": { "raw": { - "name": "/radar_raw", + "name": "/radar_k209_local_raw", "capacity": 50, "slot_size_bytes": 2097152 }, "raw_tap": { - "name": "/radar_raw_tap", + "name": "/radar_k209_local_raw_tap", "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed": { - "name": "/radar_preprocessed", + "name": "/radar_k209_local_preprocessed", "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed_tap": { - "name": "/radar_preprocessed_tap", + "name": "/radar_k209_local_preprocessed_tap", "capacity": 50, "slot_size_bytes": 2097152 }, "results": { - "name": "/radar_results", + "name": "/radar_k209_local_results", "capacity": 50, "slot_size_bytes": 2097152 } diff --git a/run_config_compact_m_k209.example.json b/run_config_compact_m_k209.example.json new file mode 100644 index 0000000..121601c --- /dev/null +++ b/run_config_compact_m_k209.example.json @@ -0,0 +1,194 @@ +{ + "radar": { + "model": "compact_m_k209", + "serial": "", + "remote_host": "192.168.1.10", + "remote_port": 50209, + "driver_mode": "native", + "mock_signal_hz": 5000000.0, + "multi_device": { + "slave_serials": [], + "force_external_reference": false, + "recovery_attempts": 3 + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "native", + "driver": "h7992", + "radar_port": 1, + "positions": 2, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 17, + "pin_b": 27, + "invert_logic": false + }, + "port2": { + "name": "port2", + "driver_mode": "native", + "driver": "h7992", + "radar_port": 2, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 22, + "pin_b": 23, + "invert_logic": false + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": true, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "locator_server": { + "device_id": 3, + "protocol_version": 1, + "host": "0.0.0.0", + "port": 8888, + "max_payload_bytes": 65536, + "client_queue_size": 32, + "logger_name": "locator_runtime" + }, + "combos": [ + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 1 + }, + { + "input": 1, + "output": 1 + }, + { + "input": 2, + "output": 1 + }, + { + "input": 3, + "output": 1 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "set_name": "", + "bundle_path": "" + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "s11": { + "calibration": { + "open": { + "set_name": "", + "bundle_path": "" + }, + "short": { + "set_name": "", + "bundle_path": "" + }, + "load": { + "set_name": "", + "bundle_path": "" + } + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "notch": { + "enabled": true, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } + }, + "gpr": { + "mode": "point", + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.905 + }, + { + "output_pos": 1, + "x_m": -0.905 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18 + }, + { + "input_pos": 1, + "x_m": 0.485 + }, + { + "input_pos": 2, + "x_m": -0.49 + }, + { + "input_pos": 3, + "x_m": 0.185 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +} diff --git a/run_config_compact_m_k209_local_mock_switches.example.json b/run_config_compact_m_k209_local_mock_switches.example.json new file mode 100644 index 0000000..f168c8b --- /dev/null +++ b/run_config_compact_m_k209_local_mock_switches.example.json @@ -0,0 +1,194 @@ +{ + "radar": { + "model": "compact_m_k209", + "serial": "", + "remote_host": "127.0.0.1", + "remote_port": 50209, + "driver_mode": "native", + "mock_signal_hz": 5000000.0, + "multi_device": { + "slave_serials": [], + "force_external_reference": false, + "recovery_attempts": 3 + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 1, + "positions": 2, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 17, + "pin_b": 27, + "invert_logic": false + }, + "port2": { + "name": "port2", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 2, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 22, + "pin_b": 23, + "invert_logic": false + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": false, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "locator_server": { + "device_id": 3, + "protocol_version": 1, + "host": "0.0.0.0", + "port": 8888, + "max_payload_bytes": 65536, + "client_queue_size": 32, + "logger_name": "locator_runtime" + }, + "combos": [ + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 1 + }, + { + "input": 1, + "output": 1 + }, + { + "input": 2, + "output": 1 + }, + { + "input": 3, + "output": 1 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "set_name": "", + "bundle_path": "" + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "s11": { + "calibration": { + "open": { + "set_name": "", + "bundle_path": "" + }, + "short": { + "set_name": "", + "bundle_path": "" + }, + "load": { + "set_name": "", + "bundle_path": "" + } + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "notch": { + "enabled": true, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } + }, + "gpr": { + "mode": "point", + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.905 + }, + { + "output_pos": 1, + "x_m": -0.905 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18 + }, + { + "input_pos": 1, + "x_m": 0.485 + }, + { + "input_pos": 2, + "x_m": -0.49 + }, + { + "input_pos": 3, + "x_m": 0.185 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_k209_local_raw", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_k209_local_raw_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_k209_local_preprocessed", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_k209_local_preprocessed_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_k209_local_results", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +} diff --git a/run_config_librevna.example.json b/run_config_librevna.example.json new file mode 100644 index 0000000..33e0709 --- /dev/null +++ b/run_config_librevna.example.json @@ -0,0 +1,192 @@ +{ + "radar": { + "model": "librevna", + "serial": "", + "driver_mode": "native", + "mock_signal_hz": 5000000.0, + "multi_device": { + "slave_serials": [], + "force_external_reference": false, + "recovery_attempts": 3 + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "native", + "driver": "h7992", + "radar_port": 1, + "positions": 2, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 17, + "pin_b": 27, + "invert_logic": false + }, + "port2": { + "name": "port2", + "driver_mode": "native", + "driver": "h7992", + "radar_port": 2, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 22, + "pin_b": 23, + "invert_logic": false + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": true, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "locator_server": { + "device_id": 3, + "protocol_version": 1, + "host": "0.0.0.0", + "port": 8888, + "max_payload_bytes": 65536, + "client_queue_size": 32, + "logger_name": "locator_runtime" + }, + "combos": [ + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 1 + }, + { + "input": 1, + "output": 1 + }, + { + "input": 2, + "output": 1 + }, + { + "input": 3, + "output": 1 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "set_name": "", + "bundle_path": "" + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "s11": { + "calibration": { + "open": { + "set_name": "", + "bundle_path": "" + }, + "short": { + "set_name": "", + "bundle_path": "" + }, + "load": { + "set_name": "", + "bundle_path": "" + } + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "notch": { + "enabled": true, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } + }, + "gpr": { + "mode": "point", + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.905 + }, + { + "output_pos": 1, + "x_m": -0.905 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18 + }, + { + "input_pos": 1, + "x_m": 0.485 + }, + { + "input_pos": 2, + "x_m": -0.49 + }, + { + "input_pos": 3, + "x_m": 0.185 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +} diff --git a/run_config_librevna_multi.example.json b/run_config_librevna_multi.example.json new file mode 100644 index 0000000..8b57014 --- /dev/null +++ b/run_config_librevna_multi.example.json @@ -0,0 +1,195 @@ +{ + "radar": { + "model": "librevna_multi", + "serial": "207730885532", + "driver_mode": "native", + "mock_signal_hz": 5000000.0, + "multi_device": { + "slave_serials": [ + "20A1307D5532", + "2072306C5532" + ], + "force_external_reference": true, + "recovery_attempts": 3 + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 1, + "positions": 2, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 17, + "pin_b": 27, + "invert_logic": false + }, + "port2": { + "name": "port2", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 2, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 22, + "pin_b": 23, + "invert_logic": false + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": true, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "locator_server": { + "device_id": 3, + "protocol_version": 1, + "host": "0.0.0.0", + "port": 8888, + "max_payload_bytes": 65536, + "client_queue_size": 32, + "logger_name": "locator_runtime" + }, + "combos": [ + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 1 + }, + { + "input": 1, + "output": 1 + }, + { + "input": 2, + "output": 1 + }, + { + "input": 3, + "output": 1 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "set_name": "", + "bundle_path": "" + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "s11": { + "calibration": { + "open": { + "set_name": "", + "bundle_path": "" + }, + "short": { + "set_name": "", + "bundle_path": "" + }, + "load": { + "set_name": "", + "bundle_path": "" + } + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "notch": { + "enabled": true, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } + }, + "gpr": { + "mode": "point", + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.905 + }, + { + "output_pos": 1, + "x_m": -0.905 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18 + }, + { + "input_pos": 1, + "x_m": 0.485 + }, + { + "input_pos": 2, + "x_m": -0.49 + }, + { + "input_pos": 3, + "x_m": 0.185 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +}