Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -35,18 +35,6 @@ class RadarDriver {
/** @brief Acquire one sweep containing the forward traces exposed by the driver. */
[[nodiscard]] virtual auto acquire_sweep() -> SweepTrace = 0;
/**
* @brief Announce which switch combo the next `acquire_sweep` belongs to.
*
* Real radars are agnostic to this because the switch state itself decides
* what they see. Mock drivers use it to synthesise per-combo variation so
* downstream plots show eight distinct traces for an eight-combo run
* instead of eight identical curves stacked on top of each other.
*
* Default implementation is a no-op so production drivers do not need to
* override.
*/
virtual void set_active_combo(const ipc::ComboKey& /*combo*/) {}
};
} // namespace radar::drivers
@@ -1,414 +0,0 @@
#include "../compact_m_k209_driver.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstring>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <utility>
namespace radar::drivers {
namespace {
constexpr std::uint32_t kFloatBytes = 4U;
constexpr std::uint32_t kComplexScalarCount = 2U;
constexpr std::uint64_t kMaxBinaryBlockBytes = 512ULL * 1024ULL * 1024ULL;
[[nodiscard]] auto visa_resource(const std::string& resource) -> ViRsrc {
return reinterpret_cast<ViRsrc>(const_cast<char*>(resource.c_str()));
}
[[nodiscard]] auto visa_buffer(std::uint8_t* data) -> ViBuf {
return reinterpret_cast<ViBuf>(data);
}
[[nodiscard]] auto visa_const_buffer(const char* data) -> ViBuf {
return reinterpret_cast<ViBuf>(const_cast<char*>(data));
}
[[nodiscard]] auto is_line_ending(std::uint8_t value) -> bool {
return value == static_cast<std::uint8_t>('\n') || value == static_cast<std::uint8_t>('\r');
}
[[nodiscard]] auto is_response_separator(std::uint8_t value) -> bool {
return is_line_ending(value) || value == static_cast<std::uint8_t>(';');
}
[[nodiscard]] auto checked_vi_count(std::uint64_t value) -> ViUInt32 {
const auto max_count = static_cast<std::uint64_t>(std::numeric_limits<ViUInt32>::max());
return static_cast<ViUInt32>(std::min(value, max_count));
}
} // namespace
CompactMK209Driver::CompactMK209Driver(CompactMK209DriverSettings settings) : settings_(std::move(settings)) {}
CompactMK209Driver::~CompactMK209Driver() {
try {
close();
} catch (...) {
}
}
void CompactMK209Driver::open() {
if (is_open_) {
return;
}
if (settings_.resource.empty()) {
throw std::runtime_error("K209 VISA resource must not be empty");
}
if (settings_.sweep.points < 2U) {
throw std::runtime_error("K209 sweep points must be >= 2");
}
if (settings_.sweep.stop_hz < settings_.sweep.start_hz) {
throw std::runtime_error("K209 sweep stop_hz must be >= start_hz");
}
if (!(settings_.sweep.if_bandwidth_hz > 0.0F)) {
throw std::runtime_error("K209 IF bandwidth must be > 0");
}
ViStatus status = viOpenDefaultRM(&resource_manager_);
if (status < VI_SUCCESS) {
throw std::runtime_error("Failed to open VISA resource manager: status=" + std::to_string(status));
}
try {
check_status(
viOpen(resource_manager_, visa_resource(settings_.resource), VI_NULL, VI_NULL, &instrument_),
"open K209 VISA resource"
);
check_status(
viSetAttribute(instrument_, VI_ATTR_TMO_VALUE, static_cast<ViAttrState>(settings_.timeout_ms)),
"set K209 VISA timeout"
);
check_status(
viSetAttribute(instrument_, VI_ATTR_TERMCHAR_EN, static_cast<ViAttrState>(VI_FALSE)),
"disable K209 VISA termchar"
);
write_command("*CLS");
configure_device();
} catch (...) {
close();
throw;
}
is_open_ = true;
}
void CompactMK209Driver::close() {
if (instrument_ != VI_NULL) {
viClose(instrument_);
instrument_ = VI_NULL;
}
if (resource_manager_ != VI_NULL) {
viClose(resource_manager_);
resource_manager_ = VI_NULL;
}
is_open_ = false;
}
auto CompactMK209Driver::acquire_sweep() -> SweepTrace {
require_open();
auto traces = query_sweep_trace_pair(settings_.sweep.points);
SweepTrace trace{};
trace.frequency_hz = frequency_hz_;
trace.s11 = complex_trace_from_interleaved(traces.first);
trace.s21 = complex_trace_from_interleaved(traces.second);
return trace;
}
auto CompactMK209Driver::query_identity() -> std::string {
require_open();
return trim_ascii(query_string("*IDN?"));
}
auto CompactMK209Driver::query_system_error() -> std::string {
require_open();
return trim_ascii(query_string("SYST:ERR?"));
}
void CompactMK209Driver::configure_device() {
if (settings_.preset_on_open) {
write_command("SYST:PRES");
expect_operation_complete("*OPC?", "wait for K209 preset");
}
write_command("SENS:FREQ:STAR " + format_frequency(settings_.sweep.start_hz));
write_command("SENS:FREQ:STOP " + format_frequency(settings_.sweep.stop_hz));
write_command("SENS:SWE:POIN " + std::to_string(settings_.sweep.points));
write_command("SENS:SWE:POIN:TIME 0");
write_command("SENS:BAND " + format_frequency(settings_.sweep.if_bandwidth_hz));
write_command("SOUR:POW " + format_power(settings_.sweep.power_dbm));
write_command("SENS:AVER OFF");
write_command("CALC:PAR1:DEF S21");
write_command("CALC:PAR1:SEL");
write_command("FORM:DATA REAL32");
write_command("FORM:BORD SWAP");
write_command("INIT:CONT ON");
write_command("TRIG:SOUR BUS");
expect_operation_complete("*OPC?", "wait for K209 setup");
frequency_hz_ = query_float_array("SENS:FREQ:DATA?", settings_.sweep.points);
}
void CompactMK209Driver::expect_operation_complete(const std::string& command, const std::string& context) {
const auto response = trim_ascii(query_string(command));
if (response != "1") {
throw std::runtime_error(context + " returned unexpected *OPC? response: " + response);
}
}
void CompactMK209Driver::write_command(const std::string& command) {
require_open();
const std::string message = command + "\n";
ViUInt32 written = 0;
check_status(
viWrite(instrument_, visa_const_buffer(message.data()), static_cast<ViUInt32>(message.size()), &written),
"write K209 command: " + command
);
if (written != message.size()) {
throw std::runtime_error("Incomplete K209 command write: " + command);
}
}
auto CompactMK209Driver::query_string(const std::string& command) -> std::string {
write_command(command);
return read_line();
}
auto CompactMK209Driver::query_float_array(
const std::string& command,
std::uint32_t expected_values
) -> std::vector<float> {
write_command(command);
return read_float_array_response(command, expected_values);
}
auto CompactMK209Driver::query_sweep_trace_pair(std::uint32_t points)
-> std::pair<std::vector<float>, std::vector<float>> {
write_command("TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21");
const auto opc_response = read_ascii_token();
if (opc_response != "1") {
throw std::runtime_error("K209 sweep returned unexpected *OPC? response: " + opc_response);
}
return {
read_float_array_response("SENS:DATA:CORR? S11", points * kComplexScalarCount),
read_float_array_response("SENS:DATA:CORR? S21", points * kComplexScalarCount),
};
}
auto CompactMK209Driver::read_float_array_response(
const std::string& context,
std::uint32_t expected_values
) -> std::vector<float> {
auto values = float_array_from_little_endian(read_ieee_block());
if (values.size() != expected_values) {
throw std::runtime_error(
"K209 response for " + context + " returned " + std::to_string(values.size()) +
" float32 values, expected " + std::to_string(expected_values)
);
}
return values;
}
auto CompactMK209Driver::read_ascii_token() -> std::string {
require_open();
std::string token{};
token.reserve(32);
while (true) {
const auto byte = read_byte();
if (is_response_separator(byte)) {
if (!token.empty()) {
return token;
}
continue;
}
token.push_back(static_cast<char>(byte));
if (token.size() > 64U * 1024U) {
throw std::runtime_error("K209 ASCII response token is too long");
}
}
}
auto CompactMK209Driver::read_line() -> std::string {
require_open();
std::string line{};
line.reserve(128);
bool has_content = false;
while (true) {
const auto byte = read_byte();
if (!has_content && is_line_ending(byte)) {
continue;
}
if (byte == static_cast<std::uint8_t>('\n')) {
break;
}
if (byte != static_cast<std::uint8_t>('\r')) {
line.push_back(static_cast<char>(byte));
has_content = true;
}
if (line.size() > 64U * 1024U) {
throw std::runtime_error("K209 ASCII response line is too long");
}
}
return line;
}
auto CompactMK209Driver::read_byte() -> std::uint8_t {
auto bytes = read_exact(1);
return bytes[0];
}
auto CompactMK209Driver::read_exact(std::uint64_t size) -> std::vector<std::uint8_t> {
require_open();
std::vector<std::uint8_t> bytes(size);
std::uint64_t offset = 0;
while (offset < size) {
ViUInt32 transferred = 0;
const auto count = checked_vi_count(size - offset);
check_status(
viRead(instrument_, visa_buffer(bytes.data() + offset), count, &transferred),
"read K209 response bytes"
);
if (transferred == 0U) {
throw std::runtime_error("K209 VISA read returned zero bytes before expected payload was complete");
}
offset += transferred;
}
return bytes;
}
auto CompactMK209Driver::read_ieee_block() -> std::vector<std::uint8_t> {
std::uint8_t marker = read_byte();
while (is_response_separator(marker)) {
marker = read_byte();
}
if (marker != static_cast<std::uint8_t>('#')) {
throw std::runtime_error("K209 binary response does not start with IEEE block marker '#'");
}
const auto digit = read_byte();
if (digit != static_cast<std::uint8_t>('8')) {
throw std::runtime_error("K209 binary response uses unsupported IEEE block header width");
}
const auto size_text_bytes = read_exact(8);
std::uint64_t payload_size = 0;
for (const auto byte : size_text_bytes) {
if (!std::isdigit(static_cast<unsigned char>(byte))) {
throw std::runtime_error("K209 binary response has non-numeric payload size");
}
payload_size = (payload_size * 10ULL) + static_cast<std::uint64_t>(byte - static_cast<std::uint8_t>('0'));
}
if (payload_size == 0U || payload_size > kMaxBinaryBlockBytes) {
throw std::runtime_error("K209 binary response payload size is out of allowed range");
}
if ((payload_size % kFloatBytes) != 0U) {
throw std::runtime_error("K209 binary response payload size is not aligned to float32 values");
}
return read_exact(payload_size);
}
void CompactMK209Driver::check_status(ViStatus status, const std::string& context) const {
if (status >= VI_SUCCESS) {
return;
}
throw std::runtime_error(context + ": " + status_message(status));
}
auto CompactMK209Driver::status_message(ViStatus status) const -> std::string {
std::array<ViChar, 256> description{};
const auto session = instrument_ != VI_NULL ? instrument_ : resource_manager_;
if (session != VI_NULL && viStatusDesc(session, status, description.data()) >= VI_SUCCESS) {
return std::string(description.data()) + " (status=" + std::to_string(status) + ")";
}
return "VISA status=" + std::to_string(status);
}
void CompactMK209Driver::require_open() const {
if (instrument_ == VI_NULL) {
throw std::runtime_error("K209 VISA instrument is not open");
}
}
auto CompactMK209Driver::float_array_from_little_endian(std::vector<std::uint8_t> bytes) -> std::vector<float> {
if ((bytes.size() % kFloatBytes) != 0U) {
throw std::runtime_error("K209 binary float payload is not aligned to 4 bytes");
}
std::vector<float> values(bytes.size() / kFloatBytes, 0.0F);
for (std::size_t index = 0; index < values.size(); ++index) {
const auto offset = index * kFloatBytes;
const std::uint32_t bits = static_cast<std::uint32_t>(bytes[offset]) |
(static_cast<std::uint32_t>(bytes[offset + 1U]) << 8U) |
(static_cast<std::uint32_t>(bytes[offset + 2U]) << 16U) |
(static_cast<std::uint32_t>(bytes[offset + 3U]) << 24U);
std::memcpy(&values[index], &bits, sizeof(float));
}
return values;
}
auto CompactMK209Driver::complex_trace_from_interleaved(const std::vector<float>& values)
-> std::vector<ipc::Complex32> {
if ((values.size() % kComplexScalarCount) != 0U) {
throw std::runtime_error("K209 complex trace payload has odd scalar count");
}
std::vector<ipc::Complex32> trace{};
trace.reserve(values.size() / kComplexScalarCount);
for (std::size_t index = 0; index < values.size(); index += kComplexScalarCount) {
trace.push_back(ipc::Complex32{.re = values[index], .im = values[index + 1U]});
}
return trace;
}
auto CompactMK209Driver::format_frequency(float value_hz) -> std::string {
if (!std::isfinite(value_hz)) {
throw std::runtime_error("K209 frequency value is not finite");
}
std::ostringstream stream;
stream.precision(9);
stream << std::fixed << value_hz;
return stream.str();
}
auto CompactMK209Driver::format_power(float value_dbm) -> std::string {
if (!std::isfinite(value_dbm)) {
throw std::runtime_error("K209 power value is not finite");
}
std::ostringstream stream;
stream.precision(3);
stream << std::fixed << value_dbm;
return stream.str();
}
auto CompactMK209Driver::trim_ascii(std::string value) -> std::string {
const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
});
const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}).base();
if (first >= last) {
return {};
}
return std::string(first, last);
}
} // namespace radar::drivers
@@ -1,71 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include <visa.h>
#include "radar_driver.hpp"
#include "run_config.hpp"
namespace radar::drivers {
struct CompactMK209DriverSettings {
std::string resource{};
config::RadarSweepSettings sweep{};
std::uint32_t timeout_ms = 20'000;
bool preset_on_open = true;
};
class CompactMK209Driver final : public RadarDriver {
public:
explicit CompactMK209Driver(CompactMK209DriverSettings settings);
~CompactMK209Driver() override;
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
[[nodiscard]] auto query_identity() -> std::string;
[[nodiscard]] auto query_system_error() -> std::string;
private:
void configure_device();
void expect_operation_complete(const std::string& command, const std::string& context);
void write_command(const std::string& command);
[[nodiscard]] auto query_string(const std::string& command) -> std::string;
[[nodiscard]] auto query_float_array(const std::string& command, std::uint32_t expected_values)
-> std::vector<float>;
[[nodiscard]] auto query_sweep_trace_pair(std::uint32_t points)
-> std::pair<std::vector<float>, std::vector<float>>;
[[nodiscard]] auto read_float_array_response(const std::string& context, std::uint32_t expected_values)
-> std::vector<float>;
[[nodiscard]] auto read_ascii_token() -> std::string;
[[nodiscard]] auto read_line() -> std::string;
[[nodiscard]] auto read_byte() -> std::uint8_t;
[[nodiscard]] auto read_exact(std::uint64_t size) -> std::vector<std::uint8_t>;
[[nodiscard]] auto read_ieee_block() -> std::vector<std::uint8_t>;
void check_status(ViStatus status, const std::string& context) const;
[[nodiscard]] auto status_message(ViStatus status) const -> std::string;
void require_open() const;
[[nodiscard]] static auto float_array_from_little_endian(std::vector<std::uint8_t> bytes) -> std::vector<float>;
[[nodiscard]] static auto complex_trace_from_interleaved(const std::vector<float>& values)
-> std::vector<ipc::Complex32>;
[[nodiscard]] static auto format_frequency(float value_hz) -> std::string;
[[nodiscard]] static auto format_power(float value_dbm) -> std::string;
[[nodiscard]] static auto trim_ascii(std::string value) -> std::string;
CompactMK209DriverSettings settings_{};
std::vector<float> frequency_hz_{};
bool is_open_ = false;
ViSession resource_manager_ = VI_NULL;
ViSession instrument_ = VI_NULL;
};
} // namespace radar::drivers
@@ -3,15 +3,11 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <complex>
#include <cstdint>
#include <exception>
#include <random>
#include <string>
#include <string_view>
#include <stdexcept>
#include <thread>
#include <utility>
#include <vector>
@@ -43,60 +39,6 @@ constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
);
}
// One synthetic GPR reflector. `range_m` is its physical depth, `reflection`
// is the dimensionless complex reflection coefficient (|Γ| ≤ 1).
struct MockTarget {
float range_m;
float reflection_magnitude;
};
// Three reflectors at GPR-typical depths: a strong near-surface scatterer,
// a mid-depth target, and a weak deeper one. The magnitudes are tuned so the
// summed S21 stays within unit modulus across the sweep band.
constexpr std::array<MockTarget, 3> kMockTargets = {{
{0.45F, 0.55F},
{1.30F, 0.30F},
{2.90F, 0.18F},
}};
// Group velocity in moderately wet soil (≈ c / sqrt(εr), εr ≈ 4). The choice
// is what maps reflector depth to round-trip phase delay; it is held constant
// to keep the simulator deterministic.
constexpr float kGroundVelocityMps = 1.5e8F;
// Soil attenuation grows with frequency. Calibrated so a target at 3 m sees
// roughly −20 dB extra loss at 6 GHz on top of geometric spreading.
constexpr float kAttenuationCoeffPerMeterAtRefHz = 0.22F;
constexpr float kAttenuationReferenceHz = 6e9F;
// Antenna mismatch dominates S11: simulate one shallow reflection right at
// the connector, plus a small amount of cross-coupling from S21 targets.
constexpr float kS11ConnectorReflection = 0.55F;
constexpr float kS11ConnectorRangeM = 0.02F;
constexpr float kS11CrossCouplingFactor = 0.06F;
// Noise floor in linear voltage units. Real LibreVNA hits ~ −90 dB at 1 kHz
// IFBW; pick something a touch noisier so the GPR processor has to work.
constexpr float kNoiseAmplitudeLinear = 0.004F;
// Minimum simulated dwell so 0-point or pathological configs don't busy-loop.
constexpr auto kMockMinimumSweepDuration = std::chrono::microseconds(50);
// Compute the dwell time the mock pretends to spend on the device. Mirrors
// the real LibreVNA contract: per-point dwell ≈ 1 / IFBW. Capped so absurd
// configs (IFBW ≈ 0 or huge sweeps) cannot freeze the producer for hours.
[[nodiscard]] auto mock_target_sweep_duration(const config::RadarSweepSettings& sweep)
-> std::chrono::nanoseconds {
const float points = std::max(1.0F, static_cast<float>(sweep.points));
const float if_bw = std::max(1.0F, sweep.if_bandwidth_hz);
const double seconds = static_cast<double>(points) / static_cast<double>(if_bw);
const auto duration_ns = std::chrono::nanoseconds(
static_cast<std::chrono::nanoseconds::rep>(seconds * 1e9)
);
constexpr auto kHardCap = std::chrono::seconds(5);
return std::clamp<std::chrono::nanoseconds>(duration_ns, kMockMinimumSweepDuration, kHardCap);
}
[[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool {
// A stop request must not be retried: drain to the caller immediately (#26).
if (message.find("aborted by stop request") != std::string_view::npos) {
@@ -139,9 +81,7 @@ void LibreVnaMinimalDriver::open() {
throw std::runtime_error("Radar sweep points must be > 0");
}
if (settings_.mode == config::DriverMode::Native) {
open_native();
}
open_native();
is_open_ = true;
}
@@ -151,166 +91,58 @@ void LibreVnaMinimalDriver::close() {
return;
}
if (settings_.mode == config::DriverMode::Native) {
close_native();
}
close_native();
is_open_ = false;
}
void LibreVnaMinimalDriver::set_active_combo(const ipc::ComboKey& combo) {
active_combo_ = combo;
}
auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
if (!is_open_) {
throw std::runtime_error("Radar driver is not open");
}
++sweep_index_;
switch (settings_.mode) {
case config::DriverMode::Mock:
return acquire_mock();
case config::DriverMode::Native: {
std::exception_ptr last_exception{};
std::string last_message{};
std::exception_ptr last_exception{};
std::string last_message{};
for (std::uint32_t attempt = 1; attempt <= kNativeAcquireMaxAttempts; ++attempt) {
try {
return acquire_native();
} catch (const std::exception& exception) {
last_exception = std::current_exception();
last_message = exception.what();
for (std::uint32_t attempt = 1; attempt <= kNativeAcquireMaxAttempts; ++attempt) {
try {
return acquire_native();
} catch (const std::exception& exception) {
last_exception = std::current_exception();
last_message = exception.what();
if (!is_retryable_native_acquire_error(last_message) || attempt == kNativeAcquireMaxAttempts) {
break;
}
if (detail::native_stop_requested()) {
break; // #26: do not waste reconnect attempts while shutting down.
}
// #31: recover from transient USB/protocol stalls without
// destroying the libusb_context. Release only the interface
// and handle, then reopen against the surviving context so
// we ride out a brief device re-enumeration instead of
// re-initialising libusb from scratch on every retry.
rx_buffer_.clear();
packet_queue_.clear();
if (usb_handle_ != nullptr && interface_claimed_) {
libusb_release_interface(usb_handle_, detail::kUsbInterface);
interface_claimed_ = false;
}
if (usb_handle_ != nullptr) {
libusb_close(usb_handle_);
usb_handle_ = nullptr;
}
protocol_version_ = 0;
device_num_ports_ = 0;
open_native(); // Reuses the surviving usb_context_ and retries discovery.
}
if (!is_retryable_native_acquire_error(last_message) || attempt == kNativeAcquireMaxAttempts) {
break;
}
if (detail::native_stop_requested()) {
break; // #26: do not waste reconnect attempts while shutting down.
}
if (last_exception != nullptr) {
std::rethrow_exception(last_exception);
// #31: recover from transient USB/protocol stalls without
// destroying the libusb_context. Release only the interface
// and handle, then reopen against the surviving context so
// we ride out a brief device re-enumeration instead of
// re-initialising libusb from scratch on every retry.
rx_buffer_.clear();
packet_queue_.clear();
if (usb_handle_ != nullptr && interface_claimed_) {
libusb_release_interface(usb_handle_, detail::kUsbInterface);
interface_claimed_ = false;
}
throw std::runtime_error("Native acquisition failed without exception detail");
if (usb_handle_ != nullptr) {
libusb_close(usb_handle_);
usb_handle_ = nullptr;
}
protocol_version_ = 0;
device_num_ports_ = 0;
open_native(); // Reuses the surviving usb_context_ and retries discovery.
}
default:
throw std::runtime_error("Unsupported radar driver mode");
}
}
auto LibreVnaMinimalDriver::acquire_mock() -> SweepTrace {
// Synthesise a frequency-domain VNA response containing several discrete
// reflectors at known depths, plus light Gaussian noise. The sweep dwells
// for a realistic duration derived from the configured IF bandwidth so
// downstream stages cannot be flooded faster than a real device would
// produce data.
const auto started_at = std::chrono::steady_clock::now();
const auto target_duration = mock_target_sweep_duration(settings_.sweep);
SweepTrace trace{};
trace.frequency_hz.reserve(settings_.sweep.points);
trace.s11.reserve(settings_.sweep.points);
trace.s21.reserve(settings_.sweep.points);
const float span_hz = settings_.sweep.stop_hz - settings_.sweep.start_hz;
const float denominator =
settings_.sweep.points > 1U ? static_cast<float>(settings_.sweep.points - 1U) : 1.0F;
// A tiny per-sweep range drift gives the rendered B-scan a visible motion
// signature so the simulator does not look frozen.
const float range_drift_m =
0.01F * std::sin(0.07F * static_cast<float>(sweep_index_));
// Combo-dependent variation. Without this the mock returns near-identical
// S21 for every (input, output) combo and an eight-combo pass-through plot
// collapses into a single visible trace. The factors below are arbitrary
// but chosen small enough that the overall response stays in a reasonable
// band and large enough that each pair is visually distinct.
const auto input_pos = static_cast<float>(active_combo_.input_pos);
const auto output_pos = static_cast<float>(active_combo_.output_pos);
const float combo_amplitude_gain = 0.55F + 0.08F * input_pos + 0.05F * output_pos;
const float combo_phase_offset = 0.4F * input_pos + 0.9F * output_pos;
const float combo_range_offset_m = 0.05F * input_pos + 0.12F * output_pos;
// Deterministic-per-sweep noise so two consecutive frames look distinct
// but the test stays reproducible for any given sweep index.
std::mt19937 noise_engine(
static_cast<std::uint32_t>(0x9E3779B9ULL ^ sweep_index_)
);
std::normal_distribution<float> noise_dist(0.0F, kNoiseAmplitudeLinear);
for (std::uint32_t point = 0; point < settings_.sweep.points; ++point) {
const float ratio = static_cast<float>(point) / denominator;
const float frequency_hz = settings_.sweep.start_hz + span_hz * ratio;
const float frequency_scale = frequency_hz / kAttenuationReferenceHz;
std::complex<float> s21_total{0.0F, 0.0F};
std::complex<float> s11_total{0.0F, 0.0F};
for (const auto& target : kMockTargets) {
const float range_m = target.range_m + range_drift_m + combo_range_offset_m;
// Round-trip phase: 2π·f·(2R/v).
const float round_trip_phase =
2.0F * detail::kPi * frequency_hz * (2.0F * range_m / kGroundVelocityMps);
// Geometric spreading: 1/(1 + R) keeps near-zero ranges finite.
const float spreading = 1.0F / (1.0F + range_m);
// Frequency-dependent soil attenuation in linear amplitude.
const float attenuation =
std::exp(-kAttenuationCoeffPerMeterAtRefHz * range_m * frequency_scale);
const std::complex<float> contribution = std::polar<float>(
target.reflection_magnitude * spreading * attenuation * combo_amplitude_gain,
-round_trip_phase + combo_phase_offset
);
s21_total += contribution;
s11_total += kS11CrossCouplingFactor * contribution;
}
// Antenna mismatch dominates the near-field S11 response.
const float antenna_phase =
2.0F * detail::kPi * frequency_hz * (2.0F * kS11ConnectorRangeM / kGroundVelocityMps);
s11_total += std::polar<float>(kS11ConnectorReflection, -antenna_phase);
// Independent noise per channel; complex variance ≈ kNoiseAmplitudeLinear².
s21_total += std::complex<float>(noise_dist(noise_engine), noise_dist(noise_engine));
s11_total += std::complex<float>(noise_dist(noise_engine), noise_dist(noise_engine));
trace.frequency_hz.push_back(frequency_hz);
trace.s11.push_back({.re = s11_total.real(), .im = s11_total.imag()});
trace.s21.push_back({.re = s21_total.real(), .im = s21_total.imag()});
}
// Honour the IFBW-derived dwell time. If generation already took longer
// than the simulated device would have needed (huge `points` × CPU jitter)
// we skip the sleep so the producer does not fall further behind.
const auto elapsed = std::chrono::steady_clock::now() - started_at;
if (elapsed < target_duration) {
std::this_thread::sleep_for(target_duration - elapsed);
if (last_exception != nullptr) {
std::rethrow_exception(last_exception);
}
return trace;
throw std::runtime_error("Native acquisition failed without exception detail");
}
auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
@@ -23,10 +23,8 @@ namespace radar::drivers {
* @brief Runtime settings for minimal LibreVNA acquisition.
*/
struct LibreVnaMinimalDriverSettings {
config::DriverMode mode = config::DriverMode::Mock;
std::string serial{};
config::RadarSweepSettings sweep{};
float mock_signal_hz = 5'000'000.0F;
};
/**
@@ -43,7 +41,6 @@ class LibreVnaMinimalDriver final : public RadarDriver {
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
void set_active_combo(const ipc::ComboKey& combo) override;
private:
/**
@@ -66,7 +63,6 @@ class LibreVnaMinimalDriver final : public RadarDriver {
bool has_s21 = false;
};
[[nodiscard]] auto acquire_mock() -> SweepTrace;
[[nodiscard]] auto acquire_native() -> SweepTrace;
void open_native();
@@ -92,11 +88,6 @@ class LibreVnaMinimalDriver final : public RadarDriver {
LibreVnaMinimalDriverSettings settings_{};
bool is_open_ = false;
std::uint64_t sweep_index_ = 0;
// Latest combo announced by the orchestrator. Used by the mock backend to
// give each (input, output) pair a slightly different reflectivity profile
// so a multi-combo run does not render as eight identical traces.
ipc::ComboKey active_combo_{};
libusb_context* usb_context_ = nullptr;
libusb_device_handle* usb_handle_ = nullptr;
@@ -1,271 +0,0 @@
#include "../remote_compact_m_k209_driver.hpp"
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace radar::drivers {
namespace {
constexpr std::uint8_t kCommandConfigure = static_cast<std::uint8_t>('C');
constexpr std::uint8_t kCommandAcquire = static_cast<std::uint8_t>('A');
constexpr std::uint8_t kStatusOk = static_cast<std::uint8_t>('O');
constexpr std::uint8_t kStatusError = static_cast<std::uint8_t>('E');
[[nodiscard]] auto system_error_message(const std::string& context) -> std::string {
return context + ": " + std::strerror(errno);
}
void append_u32_be(std::vector<std::uint8_t>& buffer, std::uint32_t value) {
buffer.push_back(static_cast<std::uint8_t>((value >> 24U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 16U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 8U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>(value & 0xFFU));
}
void append_u64_be(std::vector<std::uint8_t>& buffer, std::uint64_t value) {
for (int shift = 56; shift >= 0; shift -= 8) {
buffer.push_back(static_cast<std::uint8_t>((value >> static_cast<unsigned>(shift)) & 0xFFU));
}
}
void append_double_be(std::vector<std::uint8_t>& 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<std::uint32_t>(data[0]) << 24U) |
(static_cast<std::uint32_t>(data[1]) << 16U) |
(static_cast<std::uint32_t>(data[2]) << 8U) |
static_cast<std::uint32_t>(data[3]);
}
void send_all(int socket_fd, const void* data, std::size_t size) {
const auto* cursor = static_cast<const std::uint8_t*>(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<std::size_t>(sent);
}
}
void recv_exact(int socket_fd, void* data, std::size_t size) {
auto* cursor = static_cast<std::uint8_t*>(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<std::size_t>(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<float> {
const auto payload_size = recv_u32(socket_fd);
const auto expected_size = expected_values * static_cast<std::uint32_t>(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<float> 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<void>(::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)));
timeval timeout{};
timeout.tv_sec = static_cast<long>(timeout_ms / 1000U);
timeout.tv_usec = static_cast<long>((timeout_ms % 1000U) * 1000U);
static_cast<void>(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)));
static_cast<void>(::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<float>& values, std::uint32_t points, const std::string& context)
-> std::vector<ipc::Complex32> {
if (values.size() != static_cast<std::size_t>(points) * 2U) {
throw std::runtime_error("K209 remote " + context + " returned unexpected scalar count");
}
std::vector<ipc::Complex32> output(points);
for (std::uint32_t index = 0; index < points; ++index) {
output[index] = ipc::Complex32{
.re = values[static_cast<std::size_t>(index) * 2U],
.im = values[static_cast<std::size_t>(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<std::uint8_t> payload{};
payload.reserve(1U + 8U + 8U + 4U + 8U + 8U);
payload.push_back(kCommandConfigure);
append_double_be(payload, static_cast<double>(settings_.sweep.start_hz));
append_double_be(payload, static_cast<double>(settings_.sweep.stop_hz));
append_u32_be(payload, settings_.sweep.points);
append_double_be(payload, static_cast<double>(settings_.sweep.if_bandwidth_hz));
append_double_be(payload, static_cast<double>(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
@@ -1,34 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#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<float> frequency_hz_{};
};
} // namespace radar::drivers
@@ -10,7 +10,6 @@
#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"
@@ -20,7 +19,6 @@ 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<bool> g_stop_requested{false};
void signal_handler(int /*signal*/) {
@@ -48,26 +46,13 @@ void install_signal_handlers() {
[[nodiscard]] auto make_radar_driver(const radar::config::RadarConfig& config)
-> std::unique_ptr<radar::drivers::RadarDriver> {
if (config.model == kLibreVnaModel || config.model.empty()) {
if (config.driver_mode != radar::config::DriverMode::Native) {
throw std::runtime_error("LibreVNA simulation uses python_app.scripts.matrix_raw_producer; launch through start.sh");
}
return std::make_unique<radar::drivers::LibreVnaMinimalDriver>(
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::RemoteCompactMK209Driver>(
radar::drivers::RemoteCompactMK209DriverSettings{
.host = config.remote_host,
.port = config.remote_port,
.sweep = config.sweep,
.timeout_ms = 20'000,
}
);
}
@@ -76,10 +61,6 @@ void install_signal_handlers() {
throw std::runtime_error("librevna_multi is handled by python_app.scripts.matrix_raw_producer");
}
if (config.model == "sn9000") {
throw std::runtime_error("sn9000 is handled by python_app.scripts.matrix_raw_producer");
}
throw std::runtime_error("Unsupported radar.model for sweep_orchestrator: " + config.model);
}
@@ -1,12 +1,11 @@
#include "sweep_orchestrator.hpp"
#include "runtime_wait.hpp"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>
namespace radar::acq {
@@ -26,28 +25,6 @@ constexpr std::uint64_t kOpenRetryLogEvery = 30U;
return stop_requested.load(std::memory_order_relaxed);
}
void sleep_if_needed_ms(std::uint32_t delay_ms) {
if (delay_ms == 0U) {
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
}
// Interruptible sleep: returns true if a stop was requested while waiting.
[[nodiscard]] auto interruptible_sleep_ms(std::uint32_t delay_ms, const std::atomic<bool>& stop_requested) -> bool {
constexpr std::uint32_t kPollMs = 50U;
std::uint32_t waited = 0U;
while (waited < delay_ms) {
if (should_stop(stop_requested)) {
return true;
}
const std::uint32_t chunk = std::min(kPollMs, delay_ms - waited);
std::this_thread::sleep_for(std::chrono::milliseconds(chunk));
waited += chunk;
}
return should_stop(stop_requested);
}
void validate_sweep(const drivers::SweepTrace& sweep) {
if (sweep.frequency_hz.size() != sweep.s11.size() || sweep.frequency_hz.size() != sweep.s21.size()) {
throw std::runtime_error("Radar driver returned inconsistent sweep vectors");
@@ -66,30 +43,14 @@ class DriverLifecycleGuard {
output_switch_driver_(output_switch_driver) {}
~DriverLifecycleGuard() noexcept {
if (!active_) {
return;
}
// Shutdown should never hide the primary error path.
try {
output_switch_driver_.close();
} catch (...) {
}
try {
input_switch_driver_.close();
} catch (...) {
}
try {
radar_driver_.close();
} catch (...) {
}
close_all();
}
void open_all() {
active_ = true; // A later open failure still owns partially opened devices.
radar_driver_.open();
input_switch_driver_.open();
output_switch_driver_.open();
active_ = true;
}
// Open all devices, retrying forever with capped exponential backoff until success or
@@ -118,7 +79,7 @@ class DriverLifecycleGuard {
<< "); retrying up to every " << (kOpenRetryMaxMs / 1'000U)
<< "s until present: " << exc.what() << '\n';
}
if (interruptible_sleep_ms(delay_ms, stop_requested)) {
if (config::wait_or_stop(delay_ms, stop_requested)) {
return false;
}
delay_ms = std::min(delay_ms * 2U, kOpenRetryMaxMs);
@@ -127,14 +88,23 @@ class DriverLifecycleGuard {
return false;
}
void close_all() {
void close_all() noexcept {
if (!active_) {
return;
}
output_switch_driver_.close();
input_switch_driver_.close();
radar_driver_.close();
active_ = false;
const auto close = [](auto& driver) {
try {
driver.close();
} catch (const std::exception& error) {
std::cerr << "sweep_orchestrator: driver cleanup failed: " << error.what() << '\n';
} catch (...) {
std::cerr << "sweep_orchestrator: driver cleanup failed with an unknown error\n";
}
};
close(output_switch_driver_);
close(input_switch_driver_);
close(radar_driver_);
}
private:
@@ -238,7 +208,7 @@ void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
if (!config_.runtime.continuous) {
break;
}
sleep_if_needed_ms(config_.runtime.idle_sleep_ms);
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
continue;
}
@@ -289,11 +259,11 @@ auto SweepOrchestrator::acquire_one_collection(
// 1) switch output path, 2) switch input path, 3) optional settle.
output_switch_driver_.switch_to(combo.output_pos);
input_switch_driver_.switch_to(combo.input_pos);
sleep_if_needed_ms(config_.runtime.settling_ms);
if (config::wait_or_stop(config_.runtime.settling_ms, stop_requested)) {
interrupted = true;
break;
}
// Production drivers ignore this; mock drivers use it to give every
// (input, output) pair its own synthetic response.
radar_driver_.set_active_combo(combo);
// Stamp around the sweep only: the switch drive and settling above belong to
// neither the previous combo nor this one, so excluding them keeps the window
// an honest "when was this combo actually measured".