Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -0,0 +1,53 @@
#pragma once
#include <atomic>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include "run_config.hpp"
namespace radar::runtime {
inline std::atomic<bool> stop_requested{false};
inline auto initialize(int argc, char** argv) -> config::RunConfig {
const auto stop = [](int) { stop_requested.store(true, std::memory_order_relaxed); };
std::signal(SIGINT, stop);
std::signal(SIGTERM, stop);
std::signal(SIGPIPE, SIG_IGN);
std::string path = "run_config.json";
for (int index = 1; index + 1 < argc; ++index) {
if (std::string(argv[index]) == "--config") { path = argv[++index]; }
}
return config::load_run_config(path);
}
class FatalPipelineError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// Drop isolated malformed collections; make persistent failures conspicuous.
class CollectionErrors {
public:
void progress() { consecutive_ = 0; }
void report(const char* worker, const std::exception& error) {
++total_;
if (++consecutive_ % 500 == 0) {
std::cerr << worker << ": " << consecutive_
<< " consecutive failures — likely a permanent configuration error: "
<< error.what() << '\n';
} else if (total_ % 100 == 1) {
std::cerr << worker << ": dropped collection after error (count="
<< total_ << "): " << error.what() << '\n';
}
}
private:
std::uint64_t total_ = 0;
std::uint64_t consecutive_ = 0;
};
} // namespace radar::runtime
@@ -14,40 +14,15 @@ enum class DriverMode {
Native,
};
enum class SwitchDriverKind {
H7992,
HMC349A,
};
struct RadarSweepSettings {
// Frequency sweep bounds and acquisition settings.
float start_hz = 1'000'000.0F;
float stop_hz = 6'000'000'000.0F;
std::uint32_t points = 501;
float if_bandwidth_hz = 1'000.0F;
float power_dbm = -10.0F;
};
struct RadarConfig {
// Radar device identity and runtime mode.
std::string model = "librevna";
std::string serial{};
DriverMode driver_mode = DriverMode::Mock;
RadarSweepSettings sweep{};
};
struct SwitchConfig {
// One RF switch attached to a specific radar port.
std::string name{};
DriverMode driver_mode = DriverMode::Mock;
SwitchDriverKind driver_kind = SwitchDriverKind::H7992;
std::uint32_t radar_port = 0;
std::uint32_t positions = 4;
std::uint32_t default_position = 0;
std::string gpio_chip = "/dev/gpiochip0";
std::int32_t pin_a = 17;
std::int32_t pin_b = 27;
bool invert_logic = false;
};
struct RingEndpointConfig {
@@ -59,22 +34,18 @@ struct RingEndpointConfig {
struct RingsConfig {
RingEndpointConfig raw{.name = "/radar_raw"};
RingEndpointConfig raw_tap{.name = "/radar_raw_tap"};
RingEndpointConfig preprocessed{.name = "/radar_preprocessed"};
RingEndpointConfig preprocessed_tap{.name = "/radar_preprocessed_tap"};
RingEndpointConfig results{.name = "/radar_results"};
};
struct RuntimeConfig {
// Runtime pacing and behavior flags for C++ workers.
std::uint32_t settling_ms = 0;
// Runtime pacing and live processing configuration.
std::uint32_t idle_sleep_ms = 2;
bool continuous = true;
std::string processing_live_config_path = "python_app/runtime/processing_live.json";
};
struct PreprocessAssetConfig {
std::string set_name{};
std::string bundle_path{};
};
@@ -84,17 +55,6 @@ struct S21PreprocessConfig {
PreprocessAssetConfig object_calibration{};
};
struct S11CalibrationPreprocessConfig {
PreprocessAssetConfig open{};
PreprocessAssetConfig short_standard{};
PreprocessAssetConfig load{};
};
struct S11PreprocessConfig {
S11CalibrationPreprocessConfig calibration{};
PreprocessAssetConfig reference{};
};
struct PreprocessNotchBandConfig {
float low_hz = 0.0F;
float high_hz = 0.0F;
@@ -110,7 +70,6 @@ struct PreprocessNotchConfig {
struct PreprocessConfig {
// Channel-specific preprocessing assets selected by Python GUI layer.
S21PreprocessConfig s21{};
S11PreprocessConfig s11{};
PreprocessNotchConfig notch{};
};
@@ -140,7 +99,7 @@ struct GprConfig {
struct RunConfig {
// Single configuration object shared by all C++ processes.
RadarConfig radar{};
std::string radar_model = "librevna";
SwitchConfig input_switch{};
SwitchConfig output_switch{};
RingsConfig rings{};
@@ -160,7 +160,6 @@ using Json = nlohmann::json;
const auto* asset_obj = as_object(required_field(object, key), asset_context);
PreprocessAssetConfig asset{};
asset.set_name = optional_string(*asset_obj, "set_name", "");
asset.bundle_path = optional_string(*asset_obj, "bundle_path", "");
return asset;
}
@@ -242,33 +241,7 @@ using Json = nlohmann::json;
throw std::runtime_error("Unsupported driver_mode value: " + value);
}
[[nodiscard]] auto parse_switch_driver_kind(const std::string& value) -> SwitchDriverKind {
if (value == "h7992") {
return SwitchDriverKind::H7992;
}
if (value == "hmc349a") {
return SwitchDriverKind::HMC349A;
}
throw std::runtime_error("Unsupported switch driver value: " + value);
}
void validate_radar_sweep(const RadarSweepSettings& sweep) {
if (sweep.start_hz < 0.0F || sweep.stop_hz < 0.0F || !(sweep.if_bandwidth_hz > 0.0F)) {
throw std::runtime_error("radar.sweep frequencies must be non-negative and IF bandwidth must be > 0");
}
if (sweep.points == 0U) {
throw std::runtime_error("radar.sweep.points must be > 0");
}
if (sweep.stop_hz < sweep.start_hz) {
throw std::runtime_error("radar.sweep.stop_hz must be >= start_hz");
}
}
void validate_switch_config(const SwitchConfig& config) {
if (config.radar_port != 1U && config.radar_port != 2U) {
throw std::runtime_error("Switch radar_port must be 1 or 2 for " + config.name);
}
if (config.positions == 0U) {
throw std::runtime_error("Switch positions must be > 0 for " + config.name);
}
@@ -284,21 +257,11 @@ void validate_switch_config(const SwitchConfig& config) {
throw std::runtime_error("gpio_chip is required for native switch mode: " + config.name);
}
if (config.driver_kind == SwitchDriverKind::H7992) {
if (config.positions > 4U) {
throw std::runtime_error("Native H7992 switch driver supports positions <= 4 for " + config.name);
}
if (config.pin_a < 0 || config.pin_b < 0 || config.pin_a == config.pin_b) {
throw std::runtime_error("pin_a/pin_b are invalid for native H7992 mode: " + config.name);
}
return;
if (config.positions > 4U) {
throw std::runtime_error("Native H7992 switch driver supports positions <= 4 for " + config.name);
}
if (config.positions > 2U) {
throw std::runtime_error("Native HMC349A switch driver supports positions <= 2 for " + config.name);
}
if (config.pin_a < 0) {
throw std::runtime_error("pin_a (control) is required for native HMC349A mode: " + config.name);
if (config.pin_a < 0 || config.pin_b < 0 || config.pin_a == config.pin_b) {
throw std::runtime_error("pin_a/pin_b are invalid for native H7992 mode: " + config.name);
}
}
@@ -350,22 +313,24 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
[[nodiscard]] auto parse_switch_config(
const Json& object,
const std::string& default_name,
std::uint32_t default_radar_port
const std::string& default_name
) -> SwitchConfig {
const auto* switch_obj = as_object(object, default_name);
SwitchConfig config{};
config.name = optional_string(*switch_obj, "name", default_name);
config.driver_mode = parse_driver_mode(optional_string(*switch_obj, "driver_mode", "mock"));
config.driver_kind = parse_switch_driver_kind(optional_string(*switch_obj, "driver", "h7992"));
config.radar_port = optional_u32(*switch_obj, "radar_port", default_radar_port);
if (optional_string(*switch_obj, "driver", "h7992") != "h7992") {
throw std::runtime_error("Only H7992 switches are supported; check GPIO wiring before updating " + default_name);
}
if (optional_bool(*switch_obj, "invert_logic", false)) {
throw std::runtime_error("Switch invert_logic is unsupported for H7992: " + default_name);
}
config.positions = optional_u32(*switch_obj, "positions", 4);
config.default_position = optional_u32(*switch_obj, "default_position", 0);
config.gpio_chip = optional_string(*switch_obj, "gpio_chip", "/dev/gpiochip0");
config.pin_a = optional_i32(*switch_obj, "pin_a", 17);
config.pin_b = optional_i32(*switch_obj, "pin_b", 27);
config.invert_logic = optional_bool(*switch_obj, "invert_logic", false);
validate_switch_config(config);
@@ -397,6 +362,9 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
if (endpoint.slot_size_bytes % 8U != 0U) {
throw std::runtime_error("Ring slot_size_bytes must be a multiple of 8 for " + key);
}
if (endpoint.name.empty() && key == "preprocessed_tap") {
return endpoint;
}
if (endpoint.name.empty() || endpoint.name.front() != '/') {
throw std::runtime_error("Ring name must start with '/' for " + key);
}
@@ -462,59 +430,18 @@ 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.driver_mode = parse_driver_mode(optional_string(*radar_obj, "driver_mode", "mock"));
const auto* sweep_obj = as_object(required_field(*radar_obj, "sweep"), "radar.sweep");
config.radar.sweep.start_hz =
number_to_f32(as_number(required_field(*sweep_obj, "start_hz"), "radar.sweep.start_hz"), "radar.sweep.start_hz");
config.radar.sweep.stop_hz =
number_to_f32(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz"), "radar.sweep.stop_hz");
if (const auto* points_value = optional_field(*sweep_obj, "points"); points_value != nullptr) {
config.radar.sweep.points =
number_to_u32(as_number(*points_value, "radar.sweep.points"), "radar.sweep.points");
} else if (config.radar.model != "kamil_adc") {
throw std::runtime_error("Missing required config field: points");
}
config.radar.sweep.if_bandwidth_hz = number_to_f32(
as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz"
);
config.radar.sweep.power_dbm = optional_f32(*sweep_obj, "stimulus_power_dbm", -10.0F);
validate_radar_sweep(config.radar.sweep);
config.radar_model = optional_string(*radar_obj, "model", "librevna");
}
{
const auto* switches_obj = as_object(required_field(*root_obj, "switches"), "switches");
if (const auto* port1_value = optional_field(*switches_obj, "port1"); port1_value != nullptr) {
const auto* port1_obj = as_object(*port1_value, "switches.port1");
const auto* port2_obj = as_object(required_field(*switches_obj, "port2"), "switches.port2");
config.output_switch = parse_switch_config(*port1_obj, "port1", 1);
config.input_switch = parse_switch_config(*port2_obj, "port2", 2);
} else {
config.input_switch = parse_switch_config(
*as_object(required_field(*switches_obj, "input"), "switches.input"),
"input",
2
);
config.output_switch = parse_switch_config(
*as_object(required_field(*switches_obj, "output"), "switches.output"),
"output",
1
);
}
if (config.input_switch.radar_port == config.output_switch.radar_port) {
throw std::runtime_error("Switch radar_port mapping must be unique for two switches");
}
config.output_switch = parse_switch_config(required_field(*switches_obj, "port1"), "port1");
config.input_switch = parse_switch_config(required_field(*switches_obj, "port2"), "port2");
}
{
const auto* run_obj = as_object(required_field(*root_obj, "run"), "run");
config.runtime.settling_ms = optional_u32(*run_obj, "settling_ms", 0);
config.runtime.idle_sleep_ms = optional_u32(*run_obj, "idle_sleep_ms", 2);
config.runtime.continuous = optional_bool(*run_obj, "continuous", true);
config.runtime.processing_live_config_path = optional_string(
*run_obj,
"processing_live_config_path",
@@ -560,17 +487,6 @@ auto load_run_config(const std::string& path) -> RunConfig {
parse_preprocess_asset(*s21_obj, "object_calibration", "preprocess.s21");
}
const auto* s11_obj = as_object(required_field(*preprocess_obj, "s11"), "preprocess.s11");
const auto* s11_calibration_obj =
as_object(required_field(*s11_obj, "calibration"), "preprocess.s11.calibration");
config.preprocess.s11.calibration.open =
parse_preprocess_asset(*s11_calibration_obj, "open", "preprocess.s11.calibration");
config.preprocess.s11.calibration.short_standard =
parse_preprocess_asset(*s11_calibration_obj, "short", "preprocess.s11.calibration");
config.preprocess.s11.calibration.load =
parse_preprocess_asset(*s11_calibration_obj, "load", "preprocess.s11.calibration");
config.preprocess.s11.reference = parse_preprocess_asset(*s11_obj, "reference", "preprocess.s11");
if (const auto* notch_value = optional_field(*preprocess_obj, "notch"); notch_value != nullptr) {
config.preprocess.notch = parse_preprocess_notch(*as_object(*notch_value, "preprocess.notch"), "preprocess.notch");
}
@@ -585,7 +501,6 @@ auto load_run_config(const std::string& path) -> RunConfig {
{
const auto* rings_obj = as_object(required_field(*root_obj, "rings"), "rings");
config.rings.raw = parse_ring_endpoint(*rings_obj, "raw", config.rings.raw);
config.rings.raw_tap = parse_ring_endpoint(*rings_obj, "raw_tap", config.rings.raw_tap);
config.rings.preprocessed = parse_ring_endpoint(*rings_obj, "preprocessed", config.rings.preprocessed);
config.rings.preprocessed_tap = parse_ring_endpoint(
*rings_obj,
@@ -4,7 +4,6 @@
#include <string>
#include "run_config.hpp"
#include "switch_driver.hpp"
namespace radar::drivers {
@@ -27,15 +26,14 @@ struct H7992MinimalDriverSettings {
* Native mode uses Linux GPIO character-device API (uAPI v2).
* Mock mode keeps state in memory without touching hardware.
*/
class H7992MinimalDriver final : public SwitchDriver {
class H7992MinimalDriver {
public:
explicit H7992MinimalDriver(H7992MinimalDriverSettings settings);
~H7992MinimalDriver() override; // Leaves RF path safe even if close() was skipped.
~H7992MinimalDriver(); // Leaves RF path safe even if close() was skipped.
void open() override;
void close() override;
[[nodiscard]] auto position_count() const -> std::uint32_t override;
void switch_to(std::uint32_t position) override;
void open();
void close();
void switch_to(std::uint32_t position);
private:
void open_native();
@@ -83,10 +83,6 @@ void H7992MinimalDriver::close() {
is_open_ = false;
}
auto H7992MinimalDriver::position_count() const -> std::uint32_t {
return settings_.positions;
}
void H7992MinimalDriver::switch_to(std::uint32_t position) {
if (!is_open_) {
throw std::runtime_error("Switch driver is not open for " + settings_.name);
@@ -34,10 +34,8 @@ struct ComboKeyHash {
struct SweepTraceBlock {
ComboKey combo{};
// Frequency axis in Hz. Must have the same size as `s11` and `s21`.
// Frequency axis in Hz. Must have the same size as `s21`.
std::vector<float> frequency_hz{};
// Complex S11 samples for matching frequency points.
std::vector<Complex32> s11{};
// Complex S21 samples for matching frequency points.
std::vector<Complex32> s21{};
// Monotonic window in which THIS trace's sweep was measured, excluding the
@@ -92,11 +90,12 @@ struct ResultBlock {
struct ResultCollection {
std::uint64_t collection_id = 0;
std::uint64_t monotonic_ns = 0;
// Wall-clock duration of `process_collection()` for this collection,
// measured on the data_processor side with a monotonic clock. Used by the
// Python pipeline to report processing-time metrics. Zero is a valid
// "unmeasured" sentinel for legacy producers.
// Processing duration and acquisition identity, independent of stage completion time.
std::uint64_t processing_duration_ns = 0;
std::uint64_t settings_revision = 0;
std::uint64_t capture_start_ns = 0;
std::uint64_t capture_end_ns = 0;
float speed_m_s = 0.0F;
std::vector<ResultPayload> collection_payloads{};
std::vector<ResultBlock> blocks{};
};
@@ -111,7 +110,6 @@ struct ResultCollection {
-> PreprocessedCollection;
[[nodiscard]] auto serialize_result_collection(const ResultCollection& collection) -> std::vector<std::uint8_t>;
[[nodiscard]] auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> ResultCollection;
// Monotonic timestamp helper used for produced collections.
[[nodiscard]] auto current_monotonic_ns() -> std::uint64_t;
@@ -25,15 +25,7 @@ class ShmRing {
std::uint32_t slot_size_bytes
) -> ShmRing;
// Opens ring by name and validates wire-format header fields.
[[nodiscard]] static auto open_existing(const std::string& name) -> ShmRing;
// Removes shared memory object by name (safe if it does not exist).
static void unlink_ring(const std::string& name);
[[nodiscard]] auto is_open() const -> bool;
[[nodiscard]] auto capacity() const -> std::uint32_t;
[[nodiscard]] auto slot_size_bytes() const -> std::uint32_t;
[[nodiscard]] auto dropped_count() const -> std::uint64_t;
// Pushes payload with overwrite-oldest policy on overflow.
[[nodiscard]] auto push(std::span<const std::uint8_t> payload) -> bool;
@@ -57,8 +49,10 @@ class ShmRing {
void close();
static void validate_name(const std::string& name);
// Validates that mapped_size covers the geometry declared in the header
// (fix #50: reject undersized/corrupt mappings to prevent OOB slot access).
[[nodiscard]] static auto checked_mapped_size(
std::uint32_t capacity, std::uint32_t slot_size_bytes, const std::string& name
) -> std::size_t;
// Reject undersized/corrupt mappings before accessing any slot.
static void validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name);
};
@@ -1,5 +1,6 @@
#include "shared_types.hpp"
#include <bit>
#include <chrono>
#include <cstring>
#include <limits>
@@ -12,9 +13,11 @@
namespace radar::ipc {
namespace {
constexpr std::uint32_t kRawCollectionMagic = 0x32574152U; // RAW2
constexpr std::uint32_t kPreprocessedCollectionMagic = 0x32525050U; // PRP2
constexpr std::uint32_t kResultCollectionMagic = 0x324C5352U; // RSL2
static_assert(std::endian::native == std::endian::little, "IPC requires a little-endian host");
constexpr std::uint32_t kRawCollectionMagic = 0x33574152U; // RAW3
constexpr std::uint32_t kPreprocessedCollectionMagic = 0x33505250U; // PRP3
constexpr std::uint32_t kResultCollectionMagic = 0x334C5352U; // RSL3
template <typename T>
concept TriviallySerializable = std::is_trivially_copyable_v<T>;
@@ -75,16 +78,6 @@ class BinaryReader {
return value;
}
[[nodiscard]] auto read_string() -> std::string {
const auto size = read<std::uint16_t>();
ensure_available(size);
const auto* begin = reinterpret_cast<const char*>(bytes_.data() + offset_);
std::string value(begin, begin + size);
offset_ += size;
return value;
}
[[nodiscard]] auto is_consumed() const -> bool {
return offset_ == bytes_.size();
}
@@ -106,7 +99,6 @@ class BinaryReader {
void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
ensure_equal_sizes(trace.frequency_hz.size(), trace.s21.size(), "Sweep trace");
ensure_equal_sizes(trace.frequency_hz.size(), trace.s11.size(), "Sweep trace S11");
writer.write(trace.combo.input_pos);
writer.write(trace.combo.output_pos);
@@ -116,11 +108,6 @@ void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
writer.write(frequency_hz);
}
for (const auto& point : trace.s11) {
writer.write(point.re);
writer.write(point.im);
}
for (const auto& point : trace.s21) {
writer.write(point.re);
writer.write(point.im);
@@ -144,23 +131,15 @@ void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryR
trace.combo.output_pos = reader.read<std::uint32_t>();
const auto point_count = reader.read<std::uint32_t>();
// Each point encodes frequency (4B) + s11 (8B) + s21 (8B) = 20 bytes.
require_count_fits(point_count, 20U, reader);
// Each point encodes frequency (4B) + s21 (8B) = 12 bytes.
require_count_fits(point_count, 12U, reader);
trace.frequency_hz.reserve(point_count);
trace.s11.reserve(point_count);
trace.s21.reserve(point_count);
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.frequency_hz.push_back(reader.read<float>());
}
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.s11.push_back(Complex32{
.re = reader.read<float>(),
.im = reader.read<float>(),
});
}
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.s21.push_back(Complex32{
.re = reader.read<float>(),
@@ -171,10 +150,7 @@ void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryR
return trace;
}
// The capture windows live in a TRAILER after the trace blocks rather than inside
// them, so a reader built before they existed still decodes every trace and simply
// stops early. The trailer grows the same way: collection window first, then the
// per-trace window table (one pair per trace, in trace order).
// Capture trailer: collection window, then one per-trace window in trace order.
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
writer.write(magic);
writer.write(collection.collection_id);
@@ -213,22 +189,9 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
collection.traces.push_back(read_trace_block(reader));
}
// Each trailer stage is optional: a payload from an older producer stops after
// the trace blocks (or after the collection window) and leaves the rest zeroed.
if (reader.remaining_bytes() == 0U) {
return collection;
}
if (reader.remaining_bytes() < (sizeof(std::uint64_t) * 2U)) {
throw std::runtime_error("Truncated capture window in trace collection");
}
collection.capture_start_ns = reader.read<std::uint64_t>();
collection.capture_end_ns = reader.read<std::uint64_t>();
if (reader.remaining_bytes() == 0U) {
return collection;
}
const auto trace_time_count = reader.read<std::uint32_t>();
if (trace_time_count != collection.traces.size()) {
throw std::runtime_error("Per-trace capture window count does not match trace count");
@@ -254,24 +217,6 @@ void write_trace_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
auto read_trace_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
const auto point_count = reader.read<std::uint32_t>();
require_count_fits(point_count, 12U, reader);
payload->frequency_hz.reserve(point_count);
payload->trace.reserve(point_count);
for (std::uint32_t index = 0; index < point_count; ++index) {
payload->frequency_hz.push_back(reader.read<float>());
}
for (std::uint32_t index = 0; index < point_count; ++index) {
payload->trace.push_back(Complex32{
.re = reader.read<float>(),
.im = reader.read<float>(),
});
}
}
void write_image_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
const auto x_count = checked_count_to_u32(payload.image_x_axis.size(), "Result image x axis point count");
const auto y_count = checked_count_to_u32(payload.image_y_axis.size(), "Result image y axis point count");
@@ -293,27 +238,6 @@ void write_image_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
auto read_image_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
const auto x_count = reader.read<std::uint32_t>();
const auto y_count = reader.read<std::uint32_t>();
const auto value_count = static_cast<std::uint64_t>(x_count) * y_count;
require_count_fits(value_count + x_count + y_count, sizeof(float), reader);
payload->image_x_axis.reserve(x_count);
payload->image_y_axis.reserve(y_count);
payload->image_values.reserve(static_cast<std::size_t>(value_count));
for (std::uint32_t index = 0; index < x_count; ++index) {
payload->image_x_axis.push_back(reader.read<float>());
}
for (std::uint32_t index = 0; index < y_count; ++index) {
payload->image_y_axis.push_back(reader.read<float>());
}
for (std::size_t index = 0; index < value_count; ++index) {
payload->image_values.push_back(reader.read<float>());
}
}
void write_table_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
const auto column_count = payload.table_columns;
if (column_count == 0U && !payload.table_values.empty()) {
@@ -333,21 +257,6 @@ void write_table_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
auto read_table_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
const auto column_count = reader.read<std::uint32_t>();
const auto row_count = reader.read<std::uint32_t>();
if (column_count == 0U && row_count != 0U) {
throw std::runtime_error("Result table cannot have rows without columns");
}
const auto value_count = static_cast<std::uint64_t>(column_count) * row_count;
require_count_fits(value_count, sizeof(float), reader);
payload->table_columns = column_count;
payload->table_values.reserve(static_cast<std::size_t>(value_count));
for (std::size_t index = 0; index < value_count; ++index) {
payload->table_values.push_back(reader.read<float>());
}
}
void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
writer.write(static_cast<std::uint8_t>(payload.kind));
writer.write_string(payload.processing_name);
@@ -370,29 +279,6 @@ void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
}
}
[[nodiscard]] auto read_result_payload(BinaryReader& reader) -> ResultPayload {
ResultPayload payload{};
payload.kind = static_cast<ResultKind>(reader.read<std::uint8_t>());
payload.processing_name = reader.read_string();
switch (payload.kind) {
case ResultKind::TraceComplex:
read_trace_result_payload(reader, &payload);
return payload;
case ResultKind::ScalarF32:
payload.scalar_value = reader.read<float>();
return payload;
case ResultKind::ImageF32:
read_image_result_payload(reader, &payload);
return payload;
case ResultKind::TableF32:
read_table_result_payload(reader, &payload);
return payload;
default:
throw std::runtime_error("Unsupported result payload kind in stream");
}
}
void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
writer.write(block.combo.input_pos);
writer.write(block.combo.output_pos);
@@ -403,21 +289,6 @@ void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
}
}
[[nodiscard]] auto read_result_block(BinaryReader& reader) -> ResultBlock {
ResultBlock block{};
block.combo.input_pos = reader.read<std::uint32_t>();
block.combo.output_pos = reader.read<std::uint32_t>();
const auto payload_count = reader.read<std::uint32_t>();
require_count_fits(payload_count, 3U, reader);
block.payloads.reserve(payload_count);
for (std::uint32_t index = 0; index < payload_count; ++index) {
block.payloads.push_back(read_result_payload(reader));
}
return block;
}
void ensure_reader_consumed(const BinaryReader& reader, const std::string& payload_label) {
if (!reader.is_consumed()) {
throw std::runtime_error("Unexpected trailing bytes in " + payload_label);
@@ -458,6 +329,10 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
writer.write(collection.collection_id);
writer.write(collection.monotonic_ns);
writer.write(collection.processing_duration_ns);
writer.write(collection.settings_revision);
writer.write(collection.capture_start_ns);
writer.write(collection.capture_end_ns);
writer.write(collection.speed_m_s);
writer.write(checked_count_to_u32(collection.collection_payloads.size(), "Collection payload count"));
writer.write(checked_count_to_u32(collection.blocks.size(), "Result block count"));
@@ -471,37 +346,6 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
return std::move(writer).finish();
}
auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> ResultCollection {
BinaryReader reader(bytes);
const auto magic = reader.read<std::uint32_t>();
if (magic != kResultCollectionMagic) {
throw std::runtime_error("Unexpected result collection magic");
}
ResultCollection collection{};
collection.collection_id = reader.read<std::uint64_t>();
collection.monotonic_ns = reader.read<std::uint64_t>();
collection.processing_duration_ns = reader.read<std::uint64_t>();
const auto collection_payload_count = reader.read<std::uint32_t>();
const auto block_count = reader.read<std::uint32_t>();
require_count_fits(collection_payload_count, 3U, reader);
collection.collection_payloads.reserve(collection_payload_count);
for (std::uint32_t index = 0; index < collection_payload_count; ++index) {
collection.collection_payloads.push_back(read_result_payload(reader));
}
require_count_fits(block_count, 12U, reader);
collection.blocks.reserve(block_count);
for (std::uint32_t index = 0; index < block_count; ++index) {
collection.blocks.push_back(read_result_block(reader));
}
ensure_reader_consumed(reader, "result collection");
return collection;
}
auto current_monotonic_ns() -> std::uint64_t {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
@@ -44,63 +44,10 @@ constexpr auto kHeaderInitWaitTimeout = std::chrono::milliseconds(1000);
constexpr auto kHeaderInitPollInterval = std::chrono::milliseconds(2);
constexpr std::uint64_t kMaxMappedSize = 1ULL << 40U;
[[nodiscard]] auto checked_u64_diff(std::uint64_t left, std::uint64_t right) -> std::uint64_t {
return left >= right ? left - right : 0;
}
[[nodiscard]] auto errno_message(const std::string& action, const std::string& name) -> std::runtime_error {
return std::runtime_error(action + " " + name + ": " + std::strerror(errno));
}
class ScopedFd {
public:
explicit ScopedFd(int fd) : fd_(fd) {}
~ScopedFd() {
if (fd_ >= 0) {
::close(fd_);
}
}
ScopedFd(const ScopedFd&) = delete;
auto operator=(const ScopedFd&) -> ScopedFd& = delete;
ScopedFd(ScopedFd&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
auto release() -> int {
return std::exchange(fd_, -1);
}
private:
int fd_ = -1;
};
class ScopedMmap {
public:
ScopedMmap(void* address, std::size_t size) : address_(address), size_(size) {}
~ScopedMmap() {
if (address_ != nullptr && address_ != MAP_FAILED) {
munmap(address_, size_);
}
}
ScopedMmap(const ScopedMmap&) = delete;
auto operator=(const ScopedMmap&) -> ScopedMmap& = delete;
ScopedMmap(ScopedMmap&& other) noexcept
: address_(std::exchange(other.address_, nullptr)),
size_(std::exchange(other.size_, 0U)) {}
auto release() -> std::pair<void*, std::size_t> {
return {std::exchange(address_, nullptr), std::exchange(size_, 0U)};
}
private:
void* address_ = nullptr;
std::size_t size_ = 0;
};
} // namespace
ShmRing::~ShmRing() {
@@ -129,28 +76,7 @@ auto ShmRing::open_or_create(
std::uint32_t slot_size_bytes
) -> ShmRing {
validate_name(name);
if (capacity == 0U) {
throw std::runtime_error("Ring capacity must be > 0");
}
if (slot_size_bytes == 0U) {
throw std::runtime_error("Ring slot size must be > 0");
}
if (slot_size_bytes % alignof(SlotHeader) != 0U) {
throw std::runtime_error("Ring slot size must be a multiple of 8");
}
const auto stride = sizeof(SlotHeader) + static_cast<std::uint64_t>(slot_size_bytes);
if (stride > (kMaxMappedSize - sizeof(Header)) / capacity) {
throw std::runtime_error("Shared memory ring exceeds 1 TiB for " + name);
}
const auto mapped_size_u64 = sizeof(Header) + stride * capacity;
if (mapped_size_u64 > kMaxMappedSize
|| mapped_size_u64 > std::numeric_limits<std::size_t>::max()
|| mapped_size_u64 > static_cast<std::uint64_t>(std::numeric_limits<off_t>::max())) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
const auto mapped_size = static_cast<std::size_t>(mapped_size_u64);
const auto mapped_size = checked_mapped_size(capacity, slot_size_bytes, name);
bool created = false;
int fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0660);
@@ -164,8 +90,8 @@ auto ShmRing::open_or_create(
throw errno_message("Failed to open shared memory ring", name);
}
ScopedFd scoped_fd(fd);
ShmRing ring{};
ring.fd_ = fd;
if (created) {
if (ftruncate(fd, static_cast<off_t>(mapped_size)) != 0) {
@@ -195,30 +121,29 @@ auto ShmRing::open_or_create(
throw errno_message("Failed to mmap shared memory ring", name);
}
ScopedMmap scoped_mmap(mapped, mapped_size);
auto* header = static_cast<Header*>(mapped);
ring.mapped_ = mapped;
ring.mapped_size_ = mapped_size;
auto* header = ring.header_ = static_cast<Header*>(mapped);
if (created) {
// ftruncate already supplies zero-filled slots; leave them demand-paged
// instead of touching the full ring before any payload is published.
std::memset(mapped, 0, sizeof(Header));
std::memcpy(header->magic, kRingMagic.data(), kRingMagic.size());
header->version = kRingVersion;
header->capacity = capacity;
header->slot_size_bytes = slot_size_bytes;
new (&header->write_seq) std::atomic<std::uint64_t>(0);
new (&header->read_seq) std::atomic<std::uint64_t>(0);
new (&header->dropped) std::atomic<std::uint64_t>(0);
std::atomic_thread_fence(std::memory_order_release);
// Publish readiness only after geometry and cursors have been initialized.
std::atomic_ref(header->version).store(kRingVersion, std::memory_order_release);
} else {
const auto deadline = std::chrono::steady_clock::now() + kHeaderInitWaitTimeout;
while (true) {
std::atomic_thread_fence(std::memory_order_acquire);
const bool version_ok = std::atomic_ref(header->version).load(std::memory_order_acquire) == kRingVersion;
const bool magic_ok = std::memcmp(header->magic, kRingMagic.data(), kRingMagic.size()) == 0;
const bool version_ok = header->version == kRingVersion;
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
if (magic_ok && version_ok && geometry_ok) {
@@ -247,89 +172,13 @@ auto ShmRing::open_or_create(
}
}
ShmRing ring{};
ring.fd_ = scoped_fd.release();
const auto [released_mapped, released_size] = scoped_mmap.release();
ring.mapped_ = released_mapped;
ring.mapped_size_ = released_size;
ring.header_ = static_cast<Header*>(released_mapped);
return ring;
}
auto ShmRing::open_existing(const std::string& name) -> ShmRing {
validate_name(name);
const int fd = shm_open(name.c_str(), O_RDWR, 0660);
if (fd < 0) {
throw errno_message("Failed to open shared memory ring", name);
}
ScopedFd scoped_fd(fd);
struct stat info {};
if (fstat(fd, &info) != 0) {
throw errno_message("Failed to stat shared memory ring", name);
}
if (info.st_size < static_cast<off_t>(sizeof(Header))) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
}
const auto mapped_size = static_cast<std::size_t>(info.st_size);
void* mapped = mmap(nullptr, mapped_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped == MAP_FAILED) {
throw errno_message("Failed to mmap shared memory ring", name);
}
ScopedMmap scoped_mmap(mapped, mapped_size);
auto* header = static_cast<Header*>(mapped);
if (std::memcmp(header->magic, kRingMagic.data(), kRingMagic.size()) != 0) {
throw std::runtime_error("Shared memory ring magic mismatch for " + name);
}
if (header->version != kRingVersion) {
throw std::runtime_error("Shared memory ring version mismatch for " + name);
}
// ensure the mapping actually spans every slot the header describes.
validate_geometry(*header, mapped_size, name);
ShmRing ring{};
ring.fd_ = scoped_fd.release();
const auto [released_mapped, released_size] = scoped_mmap.release();
ring.mapped_ = released_mapped;
ring.mapped_size_ = released_size;
ring.header_ = static_cast<Header*>(released_mapped);
return ring;
}
void ShmRing::unlink_ring(const std::string& name) {
validate_name(name);
if (shm_unlink(name.c_str()) != 0 && errno != ENOENT) {
throw errno_message("Failed to unlink shared memory ring", name);
}
}
auto ShmRing::is_open() const -> bool {
return header_ != nullptr;
}
auto ShmRing::capacity() const -> std::uint32_t {
return header_ != nullptr ? header_->capacity : 0;
}
auto ShmRing::slot_size_bytes() const -> std::uint32_t {
return header_ != nullptr ? header_->slot_size_bytes : 0;
}
auto ShmRing::dropped_count() const -> std::uint64_t {
if (header_ == nullptr) {
return 0;
}
return header_->dropped.load(std::memory_order_acquire);
}
auto ShmRing::push(std::span<const std::uint8_t> payload) -> bool {
if (header_ == nullptr) {
throw std::runtime_error("Ring is not open");
@@ -341,7 +190,7 @@ auto ShmRing::push(std::span<const std::uint8_t> payload) -> bool {
const auto write_seq = header_->write_seq.load(std::memory_order_relaxed);
auto read_seq = header_->read_seq.load(std::memory_order_acquire);
if (checked_u64_diff(write_seq, read_seq) >= header_->capacity) {
if (write_seq >= read_seq && write_seq - read_seq >= header_->capacity) {
// Ring full: overwrite the oldest unread slot. Advance read_seq via CAS so a
// concurrent consumer's advance is never clobbered (read_seq never moves back).
header_->read_seq.compare_exchange_strong(
@@ -444,37 +293,41 @@ void ShmRing::close() {
}
void ShmRing::validate_name(const std::string& name) {
if (name.empty() || name.front() != '/') {
throw std::runtime_error("POSIX shm name must start with '/'");
if (name.size() < 2U || name.front() != '/' || name.find('/', 1) != std::string::npos
|| name.find('\0') != std::string::npos) {
throw std::runtime_error("POSIX shm name must be '/' followed by a single nonempty filename");
}
}
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
// derive the expected size from the header's own geometry fields and
// require the real mapping to cover it. A bogus capacity/slot_size or a truncated
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
const std::uint32_t capacity = header.capacity;
const std::uint32_t slot_size_bytes = header.slot_size_bytes;
auto ShmRing::checked_mapped_size(
std::uint32_t capacity, std::uint32_t slot_size_bytes, const std::string& name
) -> std::size_t {
static_assert(sizeof(Header) == 64 && sizeof(SlotHeader) == 16);
if (capacity == 0U) {
throw std::runtime_error("Shared memory ring capacity is zero for " + name);
throw std::runtime_error("Ring capacity must be > 0");
}
if (slot_size_bytes == 0U) {
throw std::runtime_error("Shared memory ring slot size is zero for " + name);
throw std::runtime_error("Ring slot size must be > 0");
}
if (slot_size_bytes % alignof(SlotHeader) != 0U) {
throw std::runtime_error("Shared memory ring slot size must be a multiple of 8 for " + name);
throw std::runtime_error("Ring slot size must be a multiple of 8");
}
const auto stride = sizeof(SlotHeader) + static_cast<std::uint64_t>(slot_size_bytes);
if (stride > (kMaxMappedSize - sizeof(Header)) / capacity) {
throw std::runtime_error("Shared memory ring exceeds 1 TiB for " + name);
}
const auto expected_size = sizeof(Header) + stride * capacity;
if (expected_size > kMaxMappedSize || expected_size > std::numeric_limits<std::size_t>::max()) {
const auto mapped_size_u64 = sizeof(Header) + stride * capacity;
if (mapped_size_u64 > kMaxMappedSize
|| mapped_size_u64 > std::numeric_limits<std::size_t>::max()
|| mapped_size_u64 > static_cast<std::uint64_t>(std::numeric_limits<off_t>::max())) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
return static_cast<std::size_t>(mapped_size_u64);
}
if (mapped_size < expected_size) {
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
if (mapped_size < checked_mapped_size(header.capacity, header.slot_size_bytes, name)) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
}
}
@@ -2,11 +2,11 @@
Standalone acquisition binary for the **L-Card E-502** ADC. It captures the
dual-channel main + reference IQ stream and publishes it as 8-byte framed packets
over a PTY, which the Python `kamil_adc` producer
(`python_app/hardware_full/kamil_adc`) reads and processes.
over a PTY. `KamilAdcService` in `python_app/hardware_full/kamil_adc` reads and
processes that stream for `python_app.scripts.kamil_adc_raw_producer`.
This is the data *source* for `radar.model == "kamil_adc"`; it is the Kamil ADC
counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
This is the data *source* for `radar.model == "kamil_adc"`, paired with the Python
`kamil_adc_raw_producer`. LibreVNA acquisition uses Python drivers.
## Layout
@@ -16,15 +16,13 @@ counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
| `include/` | Headers for the writers plus `switch_sequencer.h`. |
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
The sources started as a verbatim import of the original standalone tool, but are
**no longer identical to it**: the collector now drives the RF switches itself
(switch-aware mode, `SwitchSequencer`), stepping to the next combination inside
the hardware idle gap between sweeps and flagging a sweep *dirty* when a readout
backlog means the toggle could not have landed in that gap. It therefore reuses
the orchestrator's switch drivers and the shared `run_config` loader, which is why
its build has its own include set and its own `build/kamil/` object tree.
The wire format towards the Python producer is unchanged.
In switch-aware mode, `SwitchSequencer` drives the RF switches, stepping to the
next combination inside the hardware idle gap between sweeps and flagging a
sweep *dirty* when a readout backlog means the toggle could not have landed in
that gap. The Python reader
drops dirty sweeps; the collector repeats that combination without another
switch transition. The collector uses the common H7992 driver and `run_config`
loader, with its own include set and `build/kamil/` object tree.
## Build
@@ -33,16 +31,25 @@ make kamil_adc_collector # -> build/bin/kamil_adc_collector
```
It is **not** part of `make all`; `start.sh` builds it automatically when the
active config selects `radar.model == "kamil_adc"`. The build is self-contained:
it needs only the vendored headers and links `-ldl -lutil -lpthread`.
active config selects `radar.model == "kamil_adc"`, unless `--skip-build` is set.
The Linux build uses the repository's common sources and vendored headers and
links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
## Runtime
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
via `dlopen`, so those libraries must be on `LD_LIBRARY_PATH`. The Kamil ADC
service prepends `~/.local/lib` (their default install location) unless the config
pins `LD_LIBRARY_PATH` in `radar.kamil_adc.env`.
via `dlopen`, so those libraries must be discoverable by the dynamic loader.
The Kamil ADC service prepends `~/.local/lib` (their default install location)
unless the config pins `LD_LIBRARY_PATH` in `radar.kamil_adc.env`.
The command-line arguments (capture profile, clocking, the `do8_freq_ref`
reference overlay, etc.) are supplied by `radar.kamil_adc.args` in the run config;
the service appends the generated `tty:<path>` argument.
the service appends `tty:<path>` from `radar.kamil_adc.tty_path`.
When both RF switches use `driver_mode: "native"`, the producer also passes
`config:<path>` to the collector. After each sweep boundary, the collector emits
an 8-byte combo tag `[0x00C0, input_pos, output_pos, dirty]` (four little-endian
16-bit words). The producer accepts only clean sweeps for the requested combo.
Otherwise, the producer selects and settles the switches itself; the collector
runs without `config:<path>` and emits no combo tags. Calibration captures also
use this Python-controlled path.
@@ -6,7 +6,7 @@
#include <vector>
#include "shared_types.hpp"
#include "switch_driver.hpp"
#include "h7992_minimal_driver.hpp"
namespace radar::kamil {
@@ -47,8 +47,8 @@ class SwitchSequencer {
SwitchSequencer(
Settings settings,
std::unique_ptr<drivers::SwitchDriver> input_switch,
std::unique_ptr<drivers::SwitchDriver> output_switch
std::unique_ptr<drivers::H7992MinimalDriver> input_switch,
std::unique_ptr<drivers::H7992MinimalDriver> output_switch
)
: settings_(std::move(settings)),
input_switch_(std::move(input_switch)),
@@ -130,15 +130,14 @@ class SwitchSequencer {
private:
void apply(const radar::ipc::ComboKey& combo) {
// Output first, then input — same order the sweep orchestrator uses, so
// both acquisition paths drive an identical hardware sequence.
// Set the transmitter before the receiver.
output_switch_->switch_to(combo.output_pos);
input_switch_->switch_to(combo.input_pos);
}
Settings settings_;
std::unique_ptr<drivers::SwitchDriver> input_switch_;
std::unique_ptr<drivers::SwitchDriver> output_switch_;
std::unique_ptr<drivers::H7992MinimalDriver> input_switch_;
std::unique_ptr<drivers::H7992MinimalDriver> output_switch_;
std::size_t index_ = 0;
radar::ipc::ComboKey applied_{};
bool upcoming_dirty_ = false;
@@ -20,7 +20,6 @@
#include "switch_sequencer.h"
#include "run_config.hpp"
#include "h7992_minimal_driver.hpp"
#include "hmc349a_minimal_driver.hpp"
#include <algorithm>
#include <array>
@@ -1830,21 +1829,8 @@ void print_device_info(const t_x502_info& info) {
}
// Build one switch driver (native GPIO or mock) from a parsed switch config,
// reusing the exact driver implementations the sweep orchestrator uses so both
// acquisition paths drive identical hardware.
std::unique_ptr<radar::drivers::SwitchDriver> make_switch_driver(const radar::config::SwitchConfig& sc) {
if (sc.driver_kind == radar::config::SwitchDriverKind::HMC349A) {
return std::make_unique<radar::drivers::HMC349AMinimalDriver>(
radar::drivers::HMC349AMinimalDriverSettings{
.name = sc.name,
.mode = sc.driver_mode,
.positions = sc.positions,
.default_position = sc.default_position,
.gpio_chip = sc.gpio_chip,
.pin_a = sc.pin_a,
.invert_logic = sc.invert_logic,
});
}
// using the shared H7992 GPIO implementation.
std::unique_ptr<radar::drivers::H7992MinimalDriver> make_switch_driver(const radar::config::SwitchConfig& sc) {
return std::make_unique<radar::drivers::H7992MinimalDriver>(
radar::drivers::H7992MinimalDriverSettings{
.name = sc.name,
@@ -0,0 +1,55 @@
#pragma once
#include <span>
#include <string>
#include <unordered_map>
#include <vector>
#include "shared_types.hpp"
namespace radar::preprocessing {
struct ChannelTrace {
std::vector<float> frequency_hz{};
std::vector<ipc::Complex32> samples{};
};
class S21CalibrationBundle {
public:
void load(const std::string& path);
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
void apply(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
std::span<ipc::Complex32> measured
) const;
private:
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo_{};
};
class S21ReferenceBundle {
public:
// Load and through-calibrate the active references once at startup.
void load(const std::string& path, const S21CalibrationBundle& calibration,
const std::vector<ipc::ComboKey>& combos);
void apply(ipc::SweepTraceBlock& trace) const;
private:
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo_{};
};
// Object-derived MIMO correction, exported on each pair's runtime frequency grid.
// An empty path disables it; enabled bundles must cover every active pair.
class S21ObjectCalibrationBundle {
public:
void load(const std::string& path);
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
void apply(ipc::SweepTraceBlock& trace) const;
private:
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> factors_by_combo_{};
};
} // namespace radar::preprocessing
@@ -0,0 +1,255 @@
#include "channel_bundle_support.hpp"
#include <algorithm>
#include <cmath>
#include <complex>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <iterator>
#include <limits>
#include <span>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace radar::preprocessing {
namespace {
constexpr float kFrequencyRelativeTolerance = 1e-5F;
constexpr float kFrequencyAbsoluteTolerance = 1e-3F;
[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string {
return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos);
}
[[nodiscard]] auto read_binary_file(const std::string& path, const std::string& bundle_label)
-> std::vector<std::uint8_t> {
std::ifstream stream(path, std::ios::binary);
if (!stream.is_open()) {
throw std::runtime_error("Failed to open " + bundle_label + " bundle: " + path);
}
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
}
void validate_channel_trace_layout(
const ChannelTrace& trace,
const std::string& trace_label,
const ipc::ComboKey& combo
) {
if (trace.frequency_hz.size() != trace.samples.size()) {
throw std::runtime_error(
trace_label + " frequency/channel vector size mismatch for combo " + combo_to_string(combo)
);
}
for (std::size_t i = 0; i < trace.samples.size(); ++i) {
if (!std::isfinite(trace.frequency_hz[i]) || !std::isfinite(trace.samples[i].re)
|| !std::isfinite(trace.samples[i].im)) {
throw std::runtime_error(trace_label + " contains non-finite values for combo " + combo_to_string(combo));
}
}
}
[[nodiscard]] auto frequency_axes_match(std::span<const float> left, std::span<const float> right) -> bool {
if (left.size() != right.size()) {
return false;
}
for (std::size_t index = 0; index < left.size(); ++index) {
if (!std::isfinite(left[index]) || !std::isfinite(right[index])) {
return false;
}
const auto delta = std::fabs(left[index] - right[index]);
const auto scale = std::max(std::fabs(left[index]), std::fabs(right[index]));
const auto tolerance = std::max(kFrequencyAbsoluteTolerance, scale * kFrequencyRelativeTolerance);
if (delta > tolerance) {
return false;
}
}
return true;
}
void ensure_frequency_axis_match(
std::span<const float> expected,
std::span<const float> actual,
const std::string& label
) {
if (!frequency_axes_match(expected, actual)) {
throw std::runtime_error(label + " frequency axis mismatch");
}
}
[[nodiscard]] auto to_channel_trace(
const ipc::SweepTraceBlock& trace,
const std::string& bundle_label
) -> ChannelTrace {
if (trace.frequency_hz.empty()) {
throw std::runtime_error(bundle_label + " trace contains no frequency samples");
}
ChannelTrace channel_trace{};
channel_trace.frequency_hz = trace.frequency_hz;
channel_trace.samples = trace.s21;
validate_channel_trace_layout(channel_trace, bundle_label + " trace", trace.combo);
return channel_trace;
}
[[nodiscard]] auto load_channel_traces(
const std::string& path,
const std::string& bundle_label
) -> std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> {
const auto bytes = read_binary_file(path, bundle_label);
if (bytes.empty()) {
throw std::runtime_error(bundle_label + " bundle is empty: " + path);
}
const auto collection = ipc::deserialize_raw_collection(bytes);
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo{};
traces_by_combo.reserve(collection.traces.size());
for (const auto& trace : collection.traces) {
// #47: reject duplicate combos instead of silently overwriting an earlier trace.
const auto inserted =
traces_by_combo.insert({trace.combo, to_channel_trace(trace, bundle_label)});
if (!inserted.second) {
throw std::runtime_error(
bundle_label + " bundle contains duplicate combo " + combo_to_string(trace.combo) + ": " + path
);
}
}
if (traces_by_combo.empty()) {
throw std::runtime_error(bundle_label + " bundle does not contain traces: " + path);
}
return traces_by_combo;
}
void validate_bundle_combos(
const std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash>& traces_by_combo,
const std::vector<ipc::ComboKey>& combos,
const std::string& bundle_label
) {
if (traces_by_combo.empty()) {
return;
}
for (const auto& combo : combos) {
if (!traces_by_combo.contains(combo)) {
throw std::runtime_error(bundle_label + " is missing combo " + combo_to_string(combo));
}
}
}
template <typename TValue>
auto resolve_required(
const std::unordered_map<ipc::ComboKey, TValue, ipc::ComboKeyHash>& values_by_combo,
const ipc::ComboKey& combo,
const char* value_label
) -> const TValue& {
const auto found = values_by_combo.find(combo);
if (found == values_by_combo.end()) {
throw std::runtime_error(std::string(value_label) + " is missing for combo " + combo_to_string(combo));
}
return found->second;
}
} // namespace
void S21CalibrationBundle::load(const std::string& path) {
if (path.empty()) {
throw std::runtime_error("S21 calibration bundle path must not be empty");
}
traces_by_combo_ = load_channel_traces(path, "S21 calibration");
}
void S21CalibrationBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
validate_bundle_combos(traces_by_combo_, combos, "S21 calibration bundle");
}
void S21CalibrationBundle::apply(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
std::span<ipc::Complex32> measured
) const {
const auto& calibration_trace = resolve_required(traces_by_combo_, combo, "S21 calibration trace");
ensure_frequency_axis_match(calibration_trace.frequency_hz, frequency_hz, "S21 calibration");
if (measured.size() != calibration_trace.samples.size()) {
throw std::runtime_error("S21 calibration vector size mismatch");
}
constexpr float epsilon = 1e-12F;
for (std::size_t index = 0; index < measured.size(); ++index) {
const auto standard = calibration_trace.samples[index];
const float magnitude_squared = standard.re * standard.re + standard.im * standard.im;
if (magnitude_squared > epsilon) {
const auto value = measured[index];
measured[index] = {
(value.re * standard.re + value.im * standard.im) / magnitude_squared,
(value.im * standard.re - value.re * standard.im) / magnitude_squared,
};
}
}
}
void S21ReferenceBundle::load(
const std::string& path,
const S21CalibrationBundle& calibration,
const std::vector<ipc::ComboKey>& combos
) {
const auto raw = load_channel_traces(path, "S21 reference");
decltype(traces_by_combo_) calibrated;
for (const auto& combo : combos) {
auto trace = resolve_required(raw, combo, "S21 reference");
calibration.apply(combo, trace.frequency_hz, trace.samples);
validate_channel_trace_layout(trace, "Calibrated S21 reference", combo);
calibrated.emplace(combo, std::move(trace));
}
traces_by_combo_ = std::move(calibrated);
}
void S21ReferenceBundle::apply(ipc::SweepTraceBlock& trace) const {
const auto& reference = resolve_required(traces_by_combo_, trace.combo, "Calibrated S21 reference");
ensure_frequency_axis_match(reference.frequency_hz, trace.frequency_hz, "S21 reference");
if (trace.s21.size() != reference.samples.size()) {
throw std::runtime_error("S21 reference point count mismatch");
}
for (std::size_t index = 0; index < trace.s21.size(); ++index) {
trace.s21[index].re -= reference.samples[index].re;
trace.s21[index].im -= reference.samples[index].im;
}
}
void S21ObjectCalibrationBundle::load(const std::string& path) {
if (path.empty()) {
factors_by_combo_.clear();
return;
}
factors_by_combo_ = load_channel_traces(path, "S21 object calibration");
}
void S21ObjectCalibrationBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
validate_bundle_combos(factors_by_combo_, combos, "S21 object calibration bundle");
}
void S21ObjectCalibrationBundle::apply(ipc::SweepTraceBlock& trace) const {
if (factors_by_combo_.empty()) { return; }
const auto& factors = resolve_required(factors_by_combo_, trace.combo, "S21 object calibration");
ensure_frequency_axis_match(factors.frequency_hz, trace.frequency_hz, "S21 object calibration");
if (trace.s21.size() != factors.samples.size()) {
throw std::runtime_error("S21 object calibration vector size mismatch");
}
for (std::size_t index = 0; index < trace.s21.size(); ++index) {
const auto value = trace.s21[index];
const auto factor = factors.samples[index];
const auto corrected = std::complex<double>(value.re, value.im) * std::complex<double>(factor.re, factor.im);
if (!std::isfinite(corrected.real()) || !std::isfinite(corrected.imag()) ||
std::abs(corrected.real()) > std::numeric_limits<float>::max() ||
std::abs(corrected.imag()) > std::numeric_limits<float>::max()) {
throw std::runtime_error("S21 object calibration result must fit finite float32");
}
trace.s21[index] = {.re = static_cast<float>(corrected.real()), .im = static_cast<float>(corrected.imag())};
}
}
} // namespace radar::preprocessing
@@ -1,51 +0,0 @@
#pragma once
#include <memory>
#include <span>
#include <string>
#include <vector>
#include "calibrator_interface.hpp"
#include "channel_bundle_support.hpp"
#include "shared_types.hpp"
namespace radar::preprocessing {
class CalibrationMaster {
public:
// `s21_calibrator` encapsulates the S21 calibration algorithm.
explicit CalibrationMaster(std::unique_ptr<CalibratorInterface> s21_calibrator);
// Loads a serialized raw sweep bundle with one S21 calibration standard per combo.
void load_s21_calibration_bundle(const std::string& path);
// Loads optional one-port OSL calibration data for S11 from raw sweep bundles.
void load_s11_calibration_bundle(
const std::string& open_path,
const std::string& short_path,
const std::string& load_path
);
// Ensures all runtime combos are present in loaded standards.
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
// Applies both channel calibrations to one measured trace.
[[nodiscard]] auto apply_to_trace(const ipc::SweepTraceBlock& measured_trace) const -> ipc::SweepTraceBlock;
[[nodiscard]] auto apply_s21(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured_s21
) const -> std::vector<ipc::Complex32>;
[[nodiscard]] auto apply_s11(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured_s11
) const -> std::vector<ipc::Complex32>;
[[nodiscard]] auto has_s11_calibration() const noexcept -> bool;
private:
std::unique_ptr<CalibratorInterface> s21_calibrator_;
S21CalibrationBundle s21_calibration_bundle_{};
S11CalibrationBundle s11_calibration_bundle_{};
};
[[nodiscard]] auto make_s21_through_calibrator() -> std::unique_ptr<CalibratorInterface>;
} // namespace radar::preprocessing
@@ -1,21 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "shared_types.hpp"
namespace radar::preprocessing {
class CalibratorInterface {
public:
virtual ~CalibratorInterface() = default;
[[nodiscard]] virtual auto name() const -> std::string = 0;
[[nodiscard]] virtual auto apply(
const std::vector<ipc::Complex32>& measured,
const std::vector<ipc::Complex32>& calibration
) const -> std::vector<ipc::Complex32> = 0;
};
} // namespace radar::preprocessing
@@ -1,106 +0,0 @@
#pragma once
#include <span>
#include <string>
#include <unordered_map>
#include <vector>
#include "shared_types.hpp"
namespace radar::preprocessing {
class CalibratorInterface;
struct ChannelTrace {
std::vector<float> frequency_hz{};
std::vector<ipc::Complex32> samples{};
};
void validate_channel_trace_layout(const ChannelTrace& trace, const std::string& label, const ipc::ComboKey& combo);
[[nodiscard]] auto frequency_axes_match(std::span<const float> left, std::span<const float> right) -> bool;
class S21CalibrationBundle {
public:
void load(const std::string& path);
[[nodiscard]] auto is_enabled() const noexcept -> bool;
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
[[nodiscard]] auto apply(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured,
const CalibratorInterface& calibrator
) const -> std::vector<ipc::Complex32>;
private:
[[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const ChannelTrace&;
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo_{};
};
class S21ReferenceBundle {
public:
void load(const std::string& path);
[[nodiscard]] auto is_enabled() const noexcept -> bool;
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
[[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const ChannelTrace&;
private:
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo_{};
};
// Object-derived MIMO correction, exported on each pair's runtime frequency grid.
// An empty path disables it; enabled bundles must cover every active pair.
class S21ObjectCalibrationBundle {
public:
void load(const std::string& path);
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
void apply(ipc::SweepTraceBlock& trace) const;
private:
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> factors_by_combo_{};
};
class S11CalibrationBundle {
public:
struct Coefficients {
std::vector<float> frequency_hz{};
std::vector<ipc::Complex32> directivity{};
std::vector<ipc::Complex32> source_match{};
std::vector<ipc::Complex32> reflection_tracking{};
};
void load(
const std::string& open_path,
const std::string& short_path,
const std::string& load_path
);
[[nodiscard]] auto is_enabled() const noexcept -> bool;
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
[[nodiscard]] auto apply(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured
) const -> std::vector<ipc::Complex32>;
private:
[[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const Coefficients&;
std::unordered_map<ipc::ComboKey, Coefficients, ipc::ComboKeyHash> coefficients_by_combo_{};
};
class S11ReferenceBundle {
public:
void load(const std::string& path);
[[nodiscard]] auto is_enabled() const noexcept -> bool;
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
[[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const ChannelTrace&;
private:
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo_{};
};
} // namespace radar::preprocessing
@@ -1,63 +0,0 @@
#include "calibration_master.hpp"
#include <stdexcept>
namespace radar::preprocessing {
CalibrationMaster::CalibrationMaster(std::unique_ptr<CalibratorInterface> s21_calibrator)
: s21_calibrator_(std::move(s21_calibrator)) {
if (!s21_calibrator_) {
throw std::runtime_error("CalibrationMaster requires a non-null calibrator");
}
}
void CalibrationMaster::load_s21_calibration_bundle(const std::string& path) {
s21_calibration_bundle_.load(path);
}
void CalibrationMaster::load_s11_calibration_bundle(
const std::string& open_path,
const std::string& short_path,
const std::string& load_path
) {
s11_calibration_bundle_.load(open_path, short_path, load_path);
}
void CalibrationMaster::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
s21_calibration_bundle_.validate_combos(combos);
s11_calibration_bundle_.validate_combos(combos);
}
auto CalibrationMaster::apply_to_trace(const ipc::SweepTraceBlock& measured_trace) const -> ipc::SweepTraceBlock {
ipc::SweepTraceBlock output{};
output.combo = measured_trace.combo;
output.frequency_hz = measured_trace.frequency_hz;
output.s21 = apply_s21(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s21);
output.s11 = apply_s11(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s11);
// Calibration reshapes the samples, not when they were measured.
output.capture_start_ns = measured_trace.capture_start_ns;
output.capture_end_ns = measured_trace.capture_end_ns;
return output;
}
auto CalibrationMaster::apply_s21(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured_s21
) const -> std::vector<ipc::Complex32> {
return s21_calibration_bundle_.apply(combo, frequency_hz, measured_s21, *s21_calibrator_);
}
auto CalibrationMaster::apply_s11(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured_s11
) const -> std::vector<ipc::Complex32> {
return s11_calibration_bundle_.apply(combo, frequency_hz, measured_s11);
}
auto CalibrationMaster::has_s11_calibration() const noexcept -> bool {
return s11_calibration_bundle_.is_enabled();
}
} // namespace radar::preprocessing
@@ -1,494 +0,0 @@
#include "channel_bundle_support.hpp"
#include <algorithm>
#include <cmath>
#include <complex>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <iterator>
#include <limits>
#include <span>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include "calibrator_interface.hpp"
namespace radar::preprocessing {
namespace {
enum class RawTraceChannel {
S11,
S21,
};
constexpr float kFrequencyRelativeTolerance = 1e-5F;
constexpr float kFrequencyAbsoluteTolerance = 1e-3F;
constexpr float kComplexMagnitudeSquaredEpsilon = 1e-12F;
// #30: OSL denominator must be well conditioned relative to the magnitudes of the terms forming it.
constexpr float kOslRelativeConditioning = 1e-6F;
// #30: maximum fraction of degenerate (fallback) OSL points tolerated per combo before load fails.
constexpr float kOslMaxDegenerateFraction = 0.05F;
[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string {
return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos);
}
[[nodiscard]] auto read_binary_file(const std::string& path, const std::string& bundle_label)
-> std::vector<std::uint8_t> {
std::ifstream stream(path, std::ios::binary);
if (!stream.is_open()) {
throw std::runtime_error("Failed to open " + bundle_label + " bundle: " + path);
}
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
}
void validate_raw_trace_layout(const ipc::SweepTraceBlock& trace, const std::string& trace_label) {
if (trace.frequency_hz.size() != trace.s11.size()) {
throw std::runtime_error(
trace_label + " frequency/S11 vector size mismatch for combo " + combo_to_string(trace.combo)
);
}
if (trace.frequency_hz.size() != trace.s21.size()) {
throw std::runtime_error(
trace_label + " frequency/S21 vector size mismatch for combo " + combo_to_string(trace.combo)
);
}
}
} // namespace
void validate_channel_trace_layout(
const ChannelTrace& trace,
const std::string& trace_label,
const ipc::ComboKey& combo
) {
if (trace.frequency_hz.size() != trace.samples.size()) {
throw std::runtime_error(
trace_label + " frequency/channel vector size mismatch for combo " + combo_to_string(combo)
);
}
for (std::size_t i = 0; i < trace.samples.size(); ++i) {
if (!std::isfinite(trace.frequency_hz[i]) || !std::isfinite(trace.samples[i].re)
|| !std::isfinite(trace.samples[i].im)) {
throw std::runtime_error(trace_label + " contains non-finite values for combo " + combo_to_string(combo));
}
}
}
[[nodiscard]] auto frequency_axes_match(std::span<const float> left, std::span<const float> right) -> bool {
if (left.size() != right.size()) {
return false;
}
for (std::size_t index = 0; index < left.size(); ++index) {
if (!std::isfinite(left[index]) || !std::isfinite(right[index])) {
return false;
}
const auto delta = std::fabs(left[index] - right[index]);
const auto scale = std::max(std::fabs(left[index]), std::fabs(right[index]));
const auto tolerance = std::max(kFrequencyAbsoluteTolerance, scale * kFrequencyRelativeTolerance);
if (delta > tolerance) {
return false;
}
}
return true;
}
namespace {
void ensure_frequency_axis_match(
std::span<const float> expected,
std::span<const float> actual,
const std::string& label
) {
if (!frequency_axes_match(expected, actual)) {
throw std::runtime_error(label + " frequency axis mismatch");
}
}
[[nodiscard]] auto to_std_complex(const ipc::Complex32& value) -> std::complex<float> {
return std::complex<float>(value.re, value.im);
}
[[nodiscard]] auto from_std_complex(const std::complex<float>& value) -> ipc::Complex32 {
return ipc::Complex32{
.re = value.real(),
.im = value.imag(),
};
}
[[nodiscard]] auto pick_channel_samples(const ipc::SweepTraceBlock& trace, RawTraceChannel channel)
-> const std::vector<ipc::Complex32>& {
if (channel == RawTraceChannel::S11) {
return trace.s11;
}
return trace.s21;
}
[[nodiscard]] auto to_channel_trace(
const ipc::SweepTraceBlock& trace,
RawTraceChannel channel,
const std::string& bundle_label
) -> ChannelTrace {
validate_raw_trace_layout(trace, bundle_label + " trace");
if (trace.frequency_hz.empty()) {
throw std::runtime_error(bundle_label + " trace contains no frequency samples");
}
ChannelTrace channel_trace{};
channel_trace.frequency_hz = trace.frequency_hz;
channel_trace.samples = pick_channel_samples(trace, channel);
validate_channel_trace_layout(channel_trace, bundle_label + " trace", trace.combo);
return channel_trace;
}
[[nodiscard]] auto load_channel_traces(
const std::string& path,
const std::string& bundle_label,
RawTraceChannel channel
) -> std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> {
const auto bytes = read_binary_file(path, bundle_label);
if (bytes.empty()) {
throw std::runtime_error(bundle_label + " bundle is empty: " + path);
}
const auto collection = ipc::deserialize_raw_collection(bytes);
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> traces_by_combo{};
traces_by_combo.reserve(collection.traces.size());
for (const auto& trace : collection.traces) {
// #47: reject duplicate combos instead of silently overwriting an earlier trace.
const auto inserted =
traces_by_combo.insert({trace.combo, to_channel_trace(trace, channel, bundle_label)});
if (!inserted.second) {
throw std::runtime_error(
bundle_label + " bundle contains duplicate combo " + combo_to_string(trace.combo) + ": " + path
);
}
}
if (traces_by_combo.empty()) {
throw std::runtime_error(bundle_label + " bundle does not contain traces: " + path);
}
return traces_by_combo;
}
void validate_bundle_combos(
const std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash>& traces_by_combo,
const std::vector<ipc::ComboKey>& combos,
const std::string& bundle_label
) {
if (traces_by_combo.empty()) {
return;
}
for (const auto& combo : combos) {
if (!traces_by_combo.contains(combo)) {
throw std::runtime_error(bundle_label + " is missing combo " + combo_to_string(combo));
}
}
}
template <typename TValue>
auto resolve_required(
const std::unordered_map<ipc::ComboKey, TValue, ipc::ComboKeyHash>& values_by_combo,
const ipc::ComboKey& combo,
const char* value_label
) -> const TValue& {
const auto found = values_by_combo.find(combo);
if (found == values_by_combo.end()) {
throw std::runtime_error(std::string(value_label) + " is missing for combo " + combo_to_string(combo));
}
return found->second;
}
void ensure_combo_coverage(
const std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash>& expected,
const std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash>& actual,
const std::string& expected_label,
const std::string& actual_label
) {
for (const auto& entry : expected) {
const auto& combo = entry.first;
if (!actual.contains(combo)) {
throw std::runtime_error(
actual_label + " is missing combo " + combo_to_string(combo) +
" required by " + expected_label
);
}
}
}
[[nodiscard]] auto solve_osl_coefficients(
const ipc::ComboKey& combo,
const ChannelTrace& open_trace,
const ChannelTrace& short_trace,
const ChannelTrace& load_trace
) -> S11CalibrationBundle::Coefficients {
validate_channel_trace_layout(open_trace, "S11 open calibration", combo);
validate_channel_trace_layout(short_trace, "S11 short calibration", combo);
validate_channel_trace_layout(load_trace, "S11 load calibration", combo);
ensure_frequency_axis_match(open_trace.frequency_hz, short_trace.frequency_hz, "S11 short calibration");
ensure_frequency_axis_match(open_trace.frequency_hz, load_trace.frequency_hz, "S11 load calibration");
S11CalibrationBundle::Coefficients coefficients{};
coefficients.frequency_hz = open_trace.frequency_hz;
coefficients.directivity.resize(open_trace.frequency_hz.size());
coefficients.source_match.resize(open_trace.frequency_hz.size());
coefficients.reflection_tracking.resize(open_trace.frequency_hz.size());
// #30: track degenerate points so silently-substituted fallback coefficients cannot pass unnoticed.
std::size_t degenerate_points = 0;
for (std::size_t index = 0; index < open_trace.frequency_hz.size(); ++index) {
const auto load = to_std_complex(load_trace.samples[index]);
const auto open_delta = to_std_complex(open_trace.samples[index]) - load;
const auto short_delta = to_std_complex(short_trace.samples[index]) - load;
const auto denominator = open_delta - short_delta;
// #30: require the denominator to be well conditioned both absolutely and relative to the
// magnitudes of the open/short deltas it is formed from, so near-cancellations are rejected.
const auto denominator_norm = std::norm(denominator);
const auto relative_scale = std::max(std::norm(open_delta), std::norm(short_delta));
const auto relative_threshold = relative_scale * (kOslRelativeConditioning * kOslRelativeConditioning);
std::complex<float> source_match = std::complex<float>(0.0F, 0.0F);
std::complex<float> reflection_tracking = std::complex<float>(1.0F, 0.0F);
if (denominator_norm > kComplexMagnitudeSquaredEpsilon && denominator_norm > relative_threshold) {
source_match = (open_delta + short_delta) / denominator;
reflection_tracking = open_delta * (std::complex<float>(1.0F, 0.0F) - source_match);
} else {
// #30: degenerate point — log the offending combo/frequency and fall back to a pass-through.
++degenerate_points;
std::cerr << "S11 calibration: degenerate OSL point for combo " << combo_to_string(combo)
<< " at " << open_trace.frequency_hz[index] << " Hz (ill-conditioned denominator)\n";
}
coefficients.directivity[index] = load_trace.samples[index];
coefficients.source_match[index] = from_std_complex(source_match);
coefficients.reflection_tracking[index] = from_std_complex(reflection_tracking);
}
// #30: refuse to load a calibration whose per-combo fallback fraction exceeds the tolerated threshold.
if (!open_trace.frequency_hz.empty()) {
const auto degenerate_fraction =
static_cast<float>(degenerate_points) / static_cast<float>(open_trace.frequency_hz.size());
if (degenerate_fraction > kOslMaxDegenerateFraction) {
throw std::runtime_error(
"S11 calibration for combo " + combo_to_string(combo) + " has " +
std::to_string(degenerate_points) + " of " +
std::to_string(open_trace.frequency_hz.size()) +
" degenerate OSL points (fraction exceeds tolerance)"
);
}
}
return coefficients;
}
} // namespace
void S21CalibrationBundle::load(const std::string& path) {
if (path.empty()) {
throw std::runtime_error("S21 calibration bundle path must not be empty");
}
traces_by_combo_ = load_channel_traces(path, "S21 calibration", RawTraceChannel::S21);
}
auto S21CalibrationBundle::is_enabled() const noexcept -> bool {
return !traces_by_combo_.empty();
}
void S21CalibrationBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
validate_bundle_combos(traces_by_combo_, combos, "S21 calibration bundle");
}
auto S21CalibrationBundle::apply(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured,
const CalibratorInterface& calibrator
) const -> std::vector<ipc::Complex32> {
const auto& calibration_trace = resolve(combo);
ensure_frequency_axis_match(calibration_trace.frequency_hz, frequency_hz, "S21 calibration");
if (measured.size() != calibration_trace.samples.size()) {
throw std::runtime_error("S21 calibration vector size mismatch");
}
return calibrator.apply(measured, calibration_trace.samples);
}
auto S21CalibrationBundle::resolve(const ipc::ComboKey& combo) const -> const ChannelTrace& {
return resolve_required(traces_by_combo_, combo, "S21 calibration trace");
}
void S21ReferenceBundle::load(const std::string& path) {
if (path.empty()) {
throw std::runtime_error("S21 reference bundle path must not be empty");
}
traces_by_combo_ = load_channel_traces(path, "S21 reference", RawTraceChannel::S21);
}
auto S21ReferenceBundle::is_enabled() const noexcept -> bool {
return !traces_by_combo_.empty();
}
void S21ReferenceBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
validate_bundle_combos(traces_by_combo_, combos, "S21 reference bundle");
}
auto S21ReferenceBundle::resolve(const ipc::ComboKey& combo) const -> const ChannelTrace& {
return resolve_required(traces_by_combo_, combo, "S21 reference trace");
}
void S21ObjectCalibrationBundle::load(const std::string& path) {
if (path.empty()) {
factors_by_combo_.clear();
return;
}
factors_by_combo_ = load_channel_traces(path, "S21 object calibration", RawTraceChannel::S21);
}
void S21ObjectCalibrationBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
validate_bundle_combos(factors_by_combo_, combos, "S21 object calibration bundle");
}
void S21ObjectCalibrationBundle::apply(ipc::SweepTraceBlock& trace) const {
if (factors_by_combo_.empty()) { return; }
const auto& factors = resolve_required(factors_by_combo_, trace.combo, "S21 object calibration");
ensure_frequency_axis_match(factors.frequency_hz, trace.frequency_hz, "S21 object calibration");
if (trace.s21.size() != factors.samples.size()) {
throw std::runtime_error("S21 object calibration vector size mismatch");
}
for (std::size_t index = 0; index < trace.s21.size(); ++index) {
const auto value = trace.s21[index];
const auto factor = factors.samples[index];
const auto corrected = std::complex<double>(value.re, value.im) * std::complex<double>(factor.re, factor.im);
if (!std::isfinite(corrected.real()) || !std::isfinite(corrected.imag()) ||
std::abs(corrected.real()) > std::numeric_limits<float>::max() ||
std::abs(corrected.imag()) > std::numeric_limits<float>::max()) {
throw std::runtime_error("S21 object calibration result must fit finite float32");
}
trace.s21[index] = {.re = static_cast<float>(corrected.real()), .im = static_cast<float>(corrected.imag())};
}
}
void S11CalibrationBundle::load(
const std::string& open_path,
const std::string& short_path,
const std::string& load_path
) {
if (open_path.empty() && short_path.empty() && load_path.empty()) {
coefficients_by_combo_.clear();
// #38: S11 calibration disabled because no OSL paths were provided; surface it instead of silently skipping.
std::cerr << "S11 calibration: no open/short/load bundle paths provided; reflection correction disabled\n";
return;
}
if (open_path.empty() || short_path.empty() || load_path.empty()) {
throw std::runtime_error("S11 calibration requires open, short, and load raw bundle paths");
}
const auto open_traces = load_channel_traces(open_path, "S11 open calibration", RawTraceChannel::S11);
const auto short_traces = load_channel_traces(short_path, "S11 short calibration", RawTraceChannel::S11);
const auto load_traces = load_channel_traces(load_path, "S11 load calibration", RawTraceChannel::S11);
ensure_combo_coverage(open_traces, short_traces, "S11 open calibration", "S11 short calibration");
ensure_combo_coverage(open_traces, load_traces, "S11 open calibration", "S11 load calibration");
ensure_combo_coverage(short_traces, open_traces, "S11 short calibration", "S11 open calibration");
ensure_combo_coverage(load_traces, open_traces, "S11 load calibration", "S11 open calibration");
decltype(coefficients_by_combo_) coefficients;
coefficients.reserve(open_traces.size());
for (const auto& [combo, open_trace] : open_traces) {
coefficients.insert_or_assign(
combo,
solve_osl_coefficients(combo, open_trace, short_traces.at(combo), load_traces.at(combo))
);
}
coefficients_by_combo_ = std::move(coefficients);
}
auto S11CalibrationBundle::is_enabled() const noexcept -> bool {
return !coefficients_by_combo_.empty();
}
void S11CalibrationBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
if (!is_enabled()) {
return;
}
for (const auto& combo : combos) {
if (!coefficients_by_combo_.contains(combo)) {
throw std::runtime_error("S11 calibration data is missing for combo " + combo_to_string(combo));
}
}
}
auto S11CalibrationBundle::apply(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& measured
) const -> std::vector<ipc::Complex32> {
if (!is_enabled()) {
return measured;
}
if (frequency_hz.size() != measured.size()) {
throw std::runtime_error("S11 calibration input frequency/sample size mismatch");
}
const auto& coefficients = resolve(combo);
ensure_frequency_axis_match(coefficients.frequency_hz, frequency_hz, "S11 calibration");
if (measured.size() != coefficients.directivity.size()) {
throw std::runtime_error("S11 calibration vector size mismatch");
}
std::vector<ipc::Complex32> corrected{};
corrected.reserve(measured.size());
for (std::size_t index = 0; index < measured.size(); ++index) {
const auto numerator = to_std_complex(measured[index]) - to_std_complex(coefficients.directivity[index]);
const auto denominator =
to_std_complex(coefficients.reflection_tracking[index]) +
(to_std_complex(coefficients.source_match[index]) * numerator);
if (std::norm(denominator) > kComplexMagnitudeSquaredEpsilon) {
corrected.push_back(from_std_complex(numerator / denominator));
} else {
corrected.push_back(from_std_complex(numerator));
}
}
return corrected;
}
auto S11CalibrationBundle::resolve(const ipc::ComboKey& combo) const -> const Coefficients& {
return resolve_required(coefficients_by_combo_, combo, "S11 calibration coefficients");
}
void S11ReferenceBundle::load(const std::string& path) {
if (path.empty()) {
traces_by_combo_.clear();
// #38: S11 reference disabled because no path was provided; surface it instead of silently skipping.
std::cerr << "S11 reference: no bundle path provided; reference normalization disabled\n";
return;
}
traces_by_combo_ = load_channel_traces(path, "S11 reference", RawTraceChannel::S11);
}
auto S11ReferenceBundle::is_enabled() const noexcept -> bool {
return !traces_by_combo_.empty();
}
void S11ReferenceBundle::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
validate_bundle_combos(traces_by_combo_, combos, "S11 reference bundle");
}
auto S11ReferenceBundle::resolve(const ipc::ComboKey& combo) const -> const ChannelTrace& {
return resolve_required(traces_by_combo_, combo, "S11 reference trace");
}
} // namespace radar::preprocessing
@@ -1,76 +0,0 @@
#include "calibration_master.hpp"
#include <cstddef>
#include <memory>
#include <stdexcept>
#include <vector>
#include <Eigen/Core>
namespace radar::preprocessing {
namespace {
class ThroughCalibrator final : public CalibratorInterface {
public:
[[nodiscard]] auto name() const -> std::string override {
return "through";
}
[[nodiscard]] auto apply(
const std::vector<ipc::Complex32>& measured,
const std::vector<ipc::Complex32>& calibration
) const -> std::vector<ipc::Complex32> override {
if (measured.size() != calibration.size()) {
throw std::runtime_error("Through calibration vector size mismatch");
}
static_assert(sizeof(ipc::Complex32) == sizeof(float) * 2U, "Complex32 layout must be two contiguous floats");
constexpr float epsilon = 1e-12F;
using InterleavedComplexView = Eigen::Matrix<float, Eigen::Dynamic, 2, Eigen::RowMajor>;
const auto point_count = static_cast<Eigen::Index>(measured.size());
Eigen::Map<const InterleavedComplexView> measured_view(
reinterpret_cast<const float*>(measured.data()),
point_count,
2
);
Eigen::Map<const InterleavedComplexView> calibration_view(
reinterpret_cast<const float*>(calibration.data()),
point_count,
2
);
const auto measured_re = measured_view.col(0).array();
const auto measured_im = measured_view.col(1).array();
const auto calibration_re = calibration_view.col(0).array();
const auto calibration_im = calibration_view.col(1).array();
const Eigen::ArrayXf magnitude_squared = calibration_re.square() + calibration_im.square();
const auto stable_division_mask = (magnitude_squared > epsilon);
const Eigen::ArrayXf corrected_re = stable_division_mask.select(
((measured_re * calibration_re) + (measured_im * calibration_im)) / magnitude_squared,
measured_re
);
const Eigen::ArrayXf corrected_im = stable_division_mask.select(
((measured_im * calibration_re) - (measured_re * calibration_im)) / magnitude_squared,
measured_im
);
std::vector<ipc::Complex32> corrected(measured.size());
Eigen::Map<InterleavedComplexView> corrected_view(reinterpret_cast<float*>(corrected.data()), point_count, 2);
corrected_view.col(0) = corrected_re.matrix();
corrected_view.col(1) = corrected_im.matrix();
return corrected;
}
};
} // namespace
auto make_s21_through_calibrator() -> std::unique_ptr<CalibratorInterface> {
return std::make_unique<ThroughCalibrator>();
}
} // namespace radar::preprocessing
@@ -2,21 +2,27 @@
#include <atomic>
#include <cstdint>
#include <span>
#include <vector>
#include "calibration_master.hpp"
#include "reference_master.hpp"
#include "channel_bundle_support.hpp"
#include "run_config.hpp"
#include "shm_ring.hpp"
namespace radar::preprocessing {
// Per-point notch multipliers: 0 inside every band, 1 outside; with the cosine
// taper a raised-cosine shoulder of taper_width_hz falls from 1 to 0 towards
// each band edge.
[[nodiscard]] auto build_notch_mask(
std::span<const float> frequency_hz,
const config::PreprocessNotchConfig& notch
) -> std::vector<float>;
class DataPreprocessor {
public:
DataPreprocessor(
const config::RunConfig& config,
CalibrationMaster& calibration_master,
ReferenceMaster& reference_master,
ipc::ShmRing& raw_ring,
ipc::ShmRing& preprocessed_ring,
ipc::ShmRing* preprocessed_tap_ring = nullptr
@@ -26,19 +32,13 @@ class DataPreprocessor {
void run(const std::atomic<bool>& stop_requested);
private:
void validate_startup_prerequisites() const;
[[nodiscard]] auto try_pop_raw_collection(
std::vector<std::uint8_t>& serialized_raw,
ipc::RawSweepCollection* out_collection
) -> bool;
void publish_preprocessed_collection(const ipc::PreprocessedCollection& collection);
[[nodiscard]] auto preprocess_collection(const ipc::RawSweepCollection& raw_collection) const
-> ipc::PreprocessedCollection;
void preprocess_collection(ipc::RawSweepCollection& collection) const;
const config::RunConfig& config_;
CalibrationMaster& calibration_master_;
ReferenceMaster& reference_master_;
S21CalibrationBundle calibration_;
S21ReferenceBundle reference_;
S21ObjectCalibrationBundle object_calibration_;
ipc::ShmRing& raw_ring_;
ipc::ShmRing& preprocessed_ring_;
@@ -1,26 +1,25 @@
#include "data_preprocessor.hpp"
#include "runtime_wait.hpp"
#include "pipeline_runtime.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <numbers>
#include <span>
#include <stdexcept>
#include <string_view>
#include <utility>
#include <vector>
namespace radar::preprocessing {
namespace {
constexpr double kPi = 3.14159265358979323846;
[[nodiscard]] auto should_apply_notch(const config::PreprocessNotchConfig& notch) -> bool {
return notch.enabled && !notch.bands_hz.empty();
}
[[nodiscard]] auto build_notch_mask(
} // namespace
auto build_notch_mask(
std::span<const float> frequency_hz,
const config::PreprocessNotchConfig& notch
) -> std::vector<float> {
@@ -51,15 +50,16 @@ constexpr double kPi = 3.14159265358979323846;
continue;
}
// Raised-cosine shoulders: 1 at the outer edge, 0 where the band starts.
if (frequency >= trans_low_hz && frequency < low_hz) {
const double arg = kPi * (frequency - trans_low_hz) / taper_width_hz;
mask[index] *= static_cast<float>(0.5 * (1.0 - std::cos(arg)));
const double arg = std::numbers::pi * (frequency - trans_low_hz) / taper_width_hz;
mask[index] *= static_cast<float>(0.5 * (1.0 + std::cos(arg)));
continue;
}
if (frequency > high_hz && frequency <= trans_high_hz) {
const double arg = kPi * (frequency - high_hz) / taper_width_hz;
mask[index] *= static_cast<float>(0.5 * (1.0 + std::cos(arg)));
const double arg = std::numbers::pi * (frequency - high_hz) / taper_width_hz;
mask[index] *= static_cast<float>(0.5 * (1.0 - std::cos(arg)));
}
}
}
@@ -67,6 +67,8 @@ constexpr double kPi = 3.14159265358979323846;
return mask;
}
namespace {
void apply_notch_mask(std::vector<ipc::Complex32>& samples, std::span<const float> mask) {
const std::size_t point_count = std::min(samples.size(), mask.size());
for (std::size_t index = 0U; index < point_count; ++index) {
@@ -75,132 +77,72 @@ void apply_notch_mask(std::vector<ipc::Complex32>& samples, std::span<const floa
}
}
[[nodiscard]] auto apply_notch_filter(
const ipc::SweepTraceBlock& trace,
const config::PreprocessNotchConfig& notch
) -> ipc::SweepTraceBlock {
if (!should_apply_notch(notch)) {
return trace;
}
ipc::SweepTraceBlock filtered = trace;
const auto mask = build_notch_mask(filtered.frequency_hz, notch);
apply_notch_mask(filtered.s21, mask);
apply_notch_mask(filtered.s11, mask);
return filtered;
}
} // namespace
DataPreprocessor::DataPreprocessor(
const config::RunConfig& config,
CalibrationMaster& calibration_master,
ReferenceMaster& reference_master,
ipc::ShmRing& raw_ring,
ipc::ShmRing& preprocessed_ring,
ipc::ShmRing* preprocessed_tap_ring
)
: config_(config),
calibration_master_(calibration_master),
reference_master_(reference_master),
raw_ring_(raw_ring),
preprocessed_ring_(preprocessed_ring),
preprocessed_tap_ring_(preprocessed_tap_ring) {
calibration_.load(config_.preprocess.s21.calibration.bundle_path);
calibration_.validate_combos(config_.run_combos);
reference_.load(config_.preprocess.s21.reference.bundle_path, calibration_, config_.run_combos);
object_calibration_.load(config_.preprocess.s21.object_calibration.bundle_path);
}
void DataPreprocessor::validate_startup_prerequisites() const {
calibration_master_.validate_combos(config_.run_combos);
reference_master_.validate_combos(config_.run_combos);
object_calibration_.validate_combos(config_.run_combos);
}
auto DataPreprocessor::try_pop_raw_collection(
std::vector<std::uint8_t>& serialized_raw,
ipc::RawSweepCollection* out_collection
) -> bool {
if (!raw_ring_.pop(serialized_raw)) {
return false;
}
*out_collection = ipc::deserialize_raw_collection(serialized_raw);
return true;
}
void DataPreprocessor::publish_preprocessed_collection(const ipc::PreprocessedCollection& collection) {
const auto serialized_preprocessed = ipc::serialize_preprocessed_collection(collection);
if (!preprocessed_ring_.push(serialized_preprocessed)) {
throw std::runtime_error("Preprocessed ring slot is too small for serialized collection");
throw runtime::FatalPipelineError("Preprocessed ring slot is too small for serialized collection");
}
if (preprocessed_tap_ring_ != nullptr && !preprocessed_tap_ring_->push(serialized_preprocessed)) {
throw std::runtime_error("Preprocessed tap ring slot is too small for serialized collection");
throw runtime::FatalPipelineError("Preprocessed tap ring slot is too small for serialized collection");
}
}
void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
validate_startup_prerequisites();
std::vector<std::uint8_t> serialized_raw{};
ipc::RawSweepCollection raw_collection{};
std::uint64_t error_count = 0;
std::uint64_t consecutive_errors = 0;
runtime::CollectionErrors errors;
while (!stop_requested.load(std::memory_order_relaxed)) {
try {
if (!try_pop_raw_collection(serialized_raw, &raw_collection)) {
if (!raw_ring_.pop(serialized_raw)) {
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
continue;
}
const auto preprocessed_collection = preprocess_collection(raw_collection);
publish_preprocessed_collection(preprocessed_collection);
consecutive_errors = 0; // made progress; reset the escalation counter
auto raw_collection = ipc::deserialize_raw_collection(serialized_raw);
preprocess_collection(raw_collection);
publish_preprocessed_collection(raw_collection);
errors.progress();
} catch (const runtime::FatalPipelineError&) {
throw;
} catch (const std::exception& exc) {
// A ring-slot-too-small failure is a permanent config error (the slot
// cannot hold a serialized collection): swallowing it would silently
// drop every collection forever, so fail fast and let the operator fix it.
if (std::string(exc.what()).find("slot is too small") != std::string::npos) {
throw;
}
// A single malformed or transiently-bad collection must not kill the
// long-running preprocessor: drop it and keep serving the next sweep.
++error_count;
++consecutive_errors;
// Escalate a PERSISTENT failure loudly so a deterministic config error
// (which drops every collection forever) is not hidden by the throttle.
if (consecutive_errors % 500 == 0) {
std::cerr << "data_preprocessor: " << consecutive_errors
<< " consecutive failures — likely a permanent configuration error: "
<< exc.what() << '\n';
} else if (error_count % 100 == 1) {
std::cerr << "data_preprocessor: dropped collection after error (count="
<< error_count << "): " << exc.what() << '\n';
}
// Back-off floored at 1ms (the empty-ring sleep is never reached while a
// persistent error keeps the ring non-empty) so the catch cannot busy-spin
// a CPU core even when idle_sleep_ms is configured to 0.
errors.report("data_preprocessor", exc);
(void)config::wait_or_stop(std::max(1U, config_.runtime.idle_sleep_ms), stop_requested);
}
}
}
auto DataPreprocessor::preprocess_collection(const ipc::RawSweepCollection& raw_collection) const
-> ipc::PreprocessedCollection {
ipc::PreprocessedCollection preprocessed{};
preprocessed.collection_id = raw_collection.collection_id;
preprocessed.monotonic_ns = ipc::current_monotonic_ns();
preprocessed.capture_start_ns = raw_collection.capture_start_ns;
preprocessed.capture_end_ns = raw_collection.capture_end_ns;
preprocessed.traces.reserve(raw_collection.traces.size());
for (const auto& raw_trace : raw_collection.traces) {
// Object-derived corrections act on the completed preprocessing output.
const auto calibrated = calibration_master_.apply_to_trace(raw_trace);
const auto referenced = reference_master_.apply_to_trace(calibrated);
auto filtered = apply_notch_filter(referenced, config_.preprocess.notch);
object_calibration_.apply(filtered);
preprocessed.traces.push_back(std::move(filtered));
void DataPreprocessor::preprocess_collection(ipc::RawSweepCollection& collection) const {
if (!collection.capture_start_ns || collection.capture_end_ns < collection.capture_start_ns) {
throw std::runtime_error("Raw collection requires a valid acquisition window");
}
return preprocessed;
for (auto& trace : collection.traces) {
calibration_.apply(trace.combo, trace.frequency_hz, trace.s21);
reference_.apply(trace);
if (should_apply_notch(config_.preprocess.notch)) {
const auto mask = build_notch_mask(trace.frequency_hz, config_.preprocess.notch);
apply_notch_mask(trace.s21, mask);
}
// Object-derived corrections act on the completed preprocessing output.
object_calibration_.apply(trace);
}
collection.monotonic_ns = ipc::current_monotonic_ns();
}
} // namespace radar::preprocessing
@@ -1,49 +1,14 @@
#include <atomic>
#include <csignal>
#include <exception>
#include <iostream>
#include <string>
#include <optional>
#include "calibration_master.hpp"
#include "data_preprocessor.hpp"
#include "reference_master.hpp"
#include "run_config.hpp"
#include "pipeline_runtime.hpp"
#include "shm_ring.hpp"
namespace {
constexpr const char* kDefaultConfigPath = "run_config.json";
std::atomic<bool> g_stop_requested{false};
void signal_handler(int /*signal*/) {
g_stop_requested.store(true, std::memory_order_relaxed);
}
void install_signal_handlers() {
std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, signal_handler);
}
[[nodiscard]] auto read_config_path(int argc, char** argv) -> std::string {
std::string config_path = kDefaultConfigPath;
for (int index = 1; index < argc; ++index) {
const std::string arg = argv[index];
if (arg == "--config" && (index + 1) < argc) {
config_path = argv[++index];
}
}
return config_path;
}
} // namespace
int main(int argc, char** argv) {
install_signal_handlers();
try {
const auto config_path = read_config_path(argc, argv);
const auto config = radar::config::load_run_config(config_path);
const auto config = radar::runtime::initialize(argc, argv);
auto raw_ring = radar::ipc::ShmRing::open_or_create(
config.rings.raw.name,
@@ -55,37 +20,22 @@ int main(int argc, char** argv) {
config.rings.preprocessed.capacity,
config.rings.preprocessed.slot_size_bytes
);
auto preprocessed_tap_ring = radar::ipc::ShmRing::open_or_create(
config.rings.preprocessed_tap.name,
config.rings.preprocessed_tap.capacity,
config.rings.preprocessed_tap.slot_size_bytes
);
// Load preprocessing assets once before entering run loop.
radar::preprocessing::CalibrationMaster calibration_master(
radar::preprocessing::make_s21_through_calibrator()
);
calibration_master.load_s21_calibration_bundle(config.preprocess.s21.calibration.bundle_path);
calibration_master.load_s11_calibration_bundle(
config.preprocess.s11.calibration.open.bundle_path,
config.preprocess.s11.calibration.short_standard.bundle_path,
config.preprocess.s11.calibration.load.bundle_path
);
radar::preprocessing::ReferenceMaster reference_master;
reference_master.load_s21_reference_bundle(config.preprocess.s21.reference.bundle_path);
reference_master.load_s11_reference_bundle(config.preprocess.s11.reference.bundle_path);
reference_master.prepare_calibrated(calibration_master, config.run_combos);
std::optional<radar::ipc::ShmRing> preprocessed_tap_ring;
if (!config.rings.preprocessed_tap.name.empty()) {
preprocessed_tap_ring = radar::ipc::ShmRing::open_or_create(
config.rings.preprocessed_tap.name,
config.rings.preprocessed_tap.capacity,
config.rings.preprocessed_tap.slot_size_bytes
);
}
radar::preprocessing::DataPreprocessor preprocessor(
config,
calibration_master,
reference_master,
raw_ring,
preprocessed_ring,
&preprocessed_tap_ring
preprocessed_tap_ring ? &*preprocessed_tap_ring : nullptr
);
preprocessor.run(g_stop_requested);
preprocessor.run(radar::runtime::stop_requested);
return 0;
} catch (const std::exception& exception) {
std::cerr << "data_preprocessor error: " << exception.what() << '\n';
@@ -1,46 +0,0 @@
#pragma once
#include <span>
#include <string>
#include <unordered_map>
#include <vector>
#include "channel_bundle_support.hpp"
#include "shared_types.hpp"
namespace radar::preprocessing {
class CalibrationMaster;
class ReferenceMaster {
public:
// Loads a serialized raw sweep bundle with one S21 reference trace per combo.
void load_s21_reference_bundle(const std::string& path);
// Loads optional raw S11 reference data.
void load_s11_reference_bundle(const std::string& path);
// Builds calibrated references in memory using currently loaded raw references.
// Must be called after load_s21_reference_bundle() and after calibration standards are loaded.
void prepare_calibrated(const CalibrationMaster& calibration_master, const std::vector<ipc::ComboKey>& combos);
// Ensures all runtime combos are present in loaded references.
void validate_combos(const std::vector<ipc::ComboKey>& combos) const;
// Subtracts both channel references from one calibrated trace.
[[nodiscard]] auto apply_to_trace(const ipc::SweepTraceBlock& calibrated_trace) const -> ipc::SweepTraceBlock;
[[nodiscard]] auto apply_s21(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& calibrated_s21
) const -> std::vector<ipc::Complex32>;
[[nodiscard]] auto apply_s11(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& calibrated_s11
) const -> std::vector<ipc::Complex32>;
private:
S21ReferenceBundle raw_s21_reference_bundle_{};
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> calibrated_s21_references_by_combo_{};
S11ReferenceBundle raw_s11_reference_bundle_{};
std::unordered_map<ipc::ComboKey, ChannelTrace, ipc::ComboKeyHash> calibrated_s11_references_by_combo_{};
};
} // namespace radar::preprocessing
@@ -1,180 +0,0 @@
#include "reference_master.hpp"
#include <span>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include <Eigen/Core>
#include "calibration_master.hpp"
namespace radar::preprocessing {
namespace {
[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string {
return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos);
}
} // namespace
void ReferenceMaster::load_s21_reference_bundle(const std::string& path) {
raw_s21_reference_bundle_.load(path);
calibrated_s21_references_by_combo_.clear();
}
void ReferenceMaster::load_s11_reference_bundle(const std::string& path) {
raw_s11_reference_bundle_.load(path);
calibrated_s11_references_by_combo_.clear();
}
void ReferenceMaster::prepare_calibrated(
const CalibrationMaster& calibration_master,
const std::vector<ipc::ComboKey>& combos
) {
if (!raw_s21_reference_bundle_.is_enabled()) {
throw std::runtime_error("Reference bundle must be loaded before prepare_calibrated()");
}
calibrated_s21_references_by_combo_.clear();
calibrated_s21_references_by_combo_.reserve(combos.size());
for (const auto& combo : combos) {
const auto& raw_reference = raw_s21_reference_bundle_.resolve(combo);
ChannelTrace calibrated{};
calibrated.frequency_hz = raw_reference.frequency_hz;
calibrated.samples = calibration_master.apply_s21(combo, raw_reference.frequency_hz, raw_reference.samples);
calibrated_s21_references_by_combo_.insert_or_assign(combo, std::move(calibrated));
}
calibrated_s11_references_by_combo_.clear();
if (!raw_s11_reference_bundle_.is_enabled()) {
return;
}
if (!calibration_master.has_s11_calibration()) {
throw std::runtime_error("S11 reference bundle requires S11 calibration bundle");
}
calibrated_s11_references_by_combo_.reserve(combos.size());
for (const auto& combo : combos) {
const auto& raw_reference = raw_s11_reference_bundle_.resolve(combo);
ChannelTrace calibrated{};
calibrated.frequency_hz = raw_reference.frequency_hz;
calibrated.samples = calibration_master.apply_s11(combo, raw_reference.frequency_hz, raw_reference.samples);
calibrated_s11_references_by_combo_.insert_or_assign(combo, std::move(calibrated));
}
}
void ReferenceMaster::validate_combos(const std::vector<ipc::ComboKey>& combos) const {
if (calibrated_s21_references_by_combo_.empty()) {
throw std::runtime_error(
"Calibrated references are not prepared. Call ReferenceMaster::prepare_calibrated() at startup."
);
}
raw_s21_reference_bundle_.validate_combos(combos);
for (const auto& combo : combos) {
if (!calibrated_s21_references_by_combo_.contains(combo)) {
throw std::runtime_error("Calibrated S21 reference data is missing for combo " + combo_to_string(combo));
}
}
raw_s11_reference_bundle_.validate_combos(combos);
if (!raw_s11_reference_bundle_.is_enabled()) {
return;
}
if (calibrated_s11_references_by_combo_.empty()) {
throw std::runtime_error("Calibrated S11 references are not prepared");
}
for (const auto& combo : combos) {
if (!calibrated_s11_references_by_combo_.contains(combo)) {
throw std::runtime_error("Calibrated S11 reference data is missing for combo " + combo_to_string(combo));
}
}
}
auto ReferenceMaster::apply_to_trace(const ipc::SweepTraceBlock& calibrated_trace) const -> ipc::SweepTraceBlock {
ipc::SweepTraceBlock output{};
output.combo = calibrated_trace.combo;
output.frequency_hz = calibrated_trace.frequency_hz;
output.s21 = apply_s21(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s21);
output.s11 = apply_s11(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s11);
// Reference subtraction reshapes the samples, not when they were measured.
output.capture_start_ns = calibrated_trace.capture_start_ns;
output.capture_end_ns = calibrated_trace.capture_end_ns;
return output;
}
auto ReferenceMaster::apply_s21(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& calibrated_s21
) const -> std::vector<ipc::Complex32> {
const auto found = calibrated_s21_references_by_combo_.find(combo);
if (found == calibrated_s21_references_by_combo_.end()) {
throw std::runtime_error("Calibrated S21 reference is missing for combo " + combo_to_string(combo));
}
const auto& reference = found->second;
validate_channel_trace_layout(reference, "Calibrated S21 reference", combo);
if (calibrated_s21.size() != reference.samples.size()) {
throw std::runtime_error("S21 reference point count mismatch for combo " + combo_to_string(combo));
}
if (!frequency_axes_match(frequency_hz, reference.frequency_hz)) {
throw std::runtime_error("S21 reference frequency axis mismatch for combo " + combo_to_string(combo));
}
std::vector<ipc::Complex32> output(calibrated_s21.size());
static_assert(sizeof(ipc::Complex32) == sizeof(float) * 2U, "Complex32 layout must be two contiguous floats");
using InterleavedComplexView = Eigen::Matrix<float, Eigen::Dynamic, 2, Eigen::RowMajor>;
const auto point_count = static_cast<Eigen::Index>(calibrated_s21.size());
Eigen::Map<const InterleavedComplexView> calibrated_view(
reinterpret_cast<const float*>(calibrated_s21.data()),
point_count,
2
);
Eigen::Map<const InterleavedComplexView> reference_view(
reinterpret_cast<const float*>(reference.samples.data()),
point_count,
2
);
Eigen::Map<InterleavedComplexView> output_view(reinterpret_cast<float*>(output.data()), point_count, 2);
output_view = calibrated_view - reference_view;
return output;
}
auto ReferenceMaster::apply_s11(
const ipc::ComboKey& combo,
std::span<const float> frequency_hz,
const std::vector<ipc::Complex32>& calibrated_s11
) const -> std::vector<ipc::Complex32> {
if (!raw_s11_reference_bundle_.is_enabled()) {
return calibrated_s11;
}
const auto found = calibrated_s11_references_by_combo_.find(combo);
if (found == calibrated_s11_references_by_combo_.end()) {
throw std::runtime_error("Calibrated S11 reference is missing for combo " + combo_to_string(combo));
}
const auto& reference = found->second;
validate_channel_trace_layout(reference, "Calibrated S11 reference", combo);
if (calibrated_s11.size() != reference.samples.size()) {
throw std::runtime_error("S11 reference point count mismatch for combo " + combo_to_string(combo));
}
if (!frequency_axes_match(frequency_hz, reference.frequency_hz)) {
throw std::runtime_error("S11 reference frequency axis mismatch for combo " + combo_to_string(combo));
}
std::vector<ipc::Complex32> output(calibrated_s11.size());
for (std::size_t index = 0; index < calibrated_s11.size(); ++index) {
output[index].re = calibrated_s11[index].re - reference.samples[index].re;
output[index].im = calibrated_s11[index].im - reference.samples[index].im;
}
return output;
}
} // namespace radar::preprocessing
@@ -10,12 +10,6 @@
namespace radar::processing {
enum class HistoryCommand {
None,
RemoveLast,
ClearAll,
};
enum class LegacyGprMode {
Point,
Extended,
@@ -29,16 +23,16 @@ enum class LegacyGprReferenceMode {
FirstTxEvent,
};
struct HistoryTarget {
std::uint64_t collection_id = 0;
std::uint64_t capture_start_ns = 0;
};
struct ProcessingLiveConfig {
std::string processor_mode = "pass_through";
std::string pass_through_channel = "s21";
bool pass_through_fixed_y_enabled = false;
float pass_through_y_min_db = -100.0F;
float pass_through_y_max_db = 0.0F;
std::string bscan_axis = "abs";
std::string bscan_channel = "s21";
float bscan_cut_m = 0.0F;
float bscan_max_depth_m = 8.0F;
float bscan_cut_m = 0.824F;
float bscan_max_depth_m = 1.0F;
float bscan_gain = 1.0F;
float bscan_start_freq_mhz = 100.0F;
float bscan_stop_freq_mhz = 8800.0F;
@@ -51,13 +45,12 @@ struct ProcessingLiveConfig {
float gpr_max_depth_m = 14.0F;
float gpr_range_comp_power = 0.1F;
float gpr_comp_power = 0.2F;
// BP object-detection stop level, as a fraction of the global peak (Python
// Horns_motion_3libre.py BP_OBJECT_MIN_FRAC); peaks below it are not objects.
// BP object-detection stop level, as a fraction of the global peak; peaks
// below it are not objects.
float gpr_object_min_frac = 0.7F;
std::string gpr_score_mode = "combined";
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
// correction) or "int_focus" (focusing residual only). Mirrors the Python
// Horns_motion_3libre.py MOTION_CORRECTION_MODE selector.
// correction) or "int_focus" (focusing residual only).
std::string gpr_motion_mode = "int_minus";
float gpr_speed_m_s = 0.0F;
float gpr_look_angle_deg = 0.0F;
@@ -81,8 +74,8 @@ struct ProcessingLiveConfig {
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
float gpr_imaging_plane_y_m = 0.0F;
// Coherent BP object visibility (window + the draw limits below) is applied in
// the processor itself, matching Horns_motion_3libre.py — there is NO score
// threshold for it. Only legacy GPR still thresholds, on a pair count.
// the processor itself, with no score threshold. Only legacy GPR thresholds,
// on a pair count.
float legacy_gpr_min_visible_pair_count = 0.0F;
std::uint32_t gpr_max_detected_objects_to_draw = 0;
std::uint32_t gpr_draw_top_m_objects = 0;
@@ -111,8 +104,9 @@ struct ProcessingLiveConfig {
// "ignore socket speed" checkbox.
bool ignore_socket_speed = false;
bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None;
// Persistent desired history: normal settings writes must preserve deletions.
std::uint64_t history_clear_before_ns = 0;
std::vector<HistoryTarget> history_removed{};
std::uint64_t settings_revision = 0;
};
@@ -1,5 +1,6 @@
#include "data_processor.hpp"
#include "runtime_wait.hpp"
#include "pipeline_runtime.hpp"
#include <algorithm>
#include <chrono>
@@ -69,9 +70,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::vector<ipc::PreprocessedCollection> preprocessed_history{};
const std::size_t history_limit = replay_history_limit(config_);
std::uint64_t last_replayed_revision = live_config_loader_.revision();
std::uint64_t last_applied_history_command_seq = 0;
std::uint64_t error_count = 0;
std::uint64_t consecutive_errors = 0;
runtime::CollectionErrors errors;
std::uint64_t oversized_results = 0;
// track the socket-fed speed used for the last reprocess so a change
// arriving without a live-config revision bump still triggers a reprocess of
@@ -95,17 +94,19 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
auto& processor = resolve_processor(live_config);
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
if (live_revision != last_replayed_revision || socket_speed_dirty) {
if (live_config.history_command_seq > last_applied_history_command_seq) {
if (live_config.history_command == HistoryCommand::RemoveLast) {
if (!preprocessed_history.empty()) {
preprocessed_history.pop_back();
}
} else if (live_config.history_command == HistoryCommand::ClearAll) {
preprocessed_history.clear();
}
last_applied_history_command_seq = live_config.history_command_seq;
const auto was_removed = [&](const ipc::PreprocessedCollection& collection) {
if (live_config.history_clear_before_ns &&
collection.capture_start_ns <= live_config.history_clear_before_ns) {
return true;
}
return std::any_of(live_config.history_removed.begin(), live_config.history_removed.end(),
[&](const HistoryTarget& target) {
return collection.collection_id == target.collection_id &&
collection.capture_start_ns == target.capture_start_ns;
});
};
if (live_revision != last_replayed_revision || socket_speed_dirty) {
std::erase_if(preprocessed_history, was_removed);
if (!live_config.reprocess_current_result) {
// Socket-fed speed updates should affect only future preprocessed collections,
@@ -139,6 +140,12 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
if (preprocessed_ring_.pop(bytes)) {
auto preprocessed = ipc::deserialize_preprocessed_collection(bytes);
if (!preprocessed.capture_start_ns || preprocessed.capture_end_ns < preprocessed.capture_start_ns) {
throw std::runtime_error("Preprocessed collection requires a valid acquisition window");
}
if (was_removed(preprocessed)) {
continue; // A deletion also applies to queued input and later GUI replays.
}
// Sweeps the GUI feeds back to recompute its B-scan keep their original
// timestamps, so they are no newer than the newest retained sweep. They
// are processed like any other but stay out of the history, which must
@@ -146,7 +153,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
// background from it), and away from the locator, which reports the
// current scene.
const bool fed_back = !preprocessed_history.empty() &&
preprocessed.monotonic_ns <= preprocessed_history.back().monotonic_ns;
preprocessed.capture_start_ns <= preprocessed_history.back().capture_start_ns;
if (!fed_back) {
preprocessed_history.push_back(std::move(preprocessed));
while (preprocessed_history.size() > history_limit) {
@@ -157,7 +164,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
const auto earlier = std::partition_point(
preprocessed_history.begin(),
preprocessed_history.end(),
[&](const ipc::PreprocessedCollection& retained) { return retained.monotonic_ns < current.monotonic_ns; }
[&](const ipc::PreprocessedCollection& retained) { return retained.capture_start_ns < current.capture_start_ns; }
);
const auto result_collection = process_collection(
@@ -170,29 +177,13 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
if (!fed_back) {
publish_locator(result_collection, live_config);
}
consecutive_errors = 0; // made progress; reset the escalation counter
errors.progress();
continue;
}
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
} catch (const std::exception& exc) {
// A single bad collection (torn ring slot, decode/processing error) must
// not kill the long-running processor: drop it and keep going.
++error_count;
++consecutive_errors;
// Escalate a PERSISTENT failure (a deterministic config error drops every
// collection forever): log it loudly and distinctly — not buried in the
// every-100 throttle — so liveness-only supervision still surfaces it.
if (consecutive_errors % 500 == 0) {
std::cerr << "data_processor: " << consecutive_errors
<< " consecutive failures — likely a permanent configuration error: "
<< exc.what() << '\n';
} else if (error_count % 100 == 1) {
std::cerr << "data_processor: dropped collection after error (count="
<< error_count << "): " << exc.what() << '\n';
}
// Back-off (floored at 1ms even if idle_sleep_ms==0) so a persistent error
// cannot busy-spin a core or flood the log.
errors.report("data_processor", exc);
(void)config::wait_or_stop(std::max(1U, config_.runtime.idle_sleep_ms), stop_requested);
}
}
@@ -210,15 +201,13 @@ auto DataProcessor::process_collection(
result.processing_duration_ns = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(finished_at - started_at).count()
);
// Four 16-bit words retain the uint64 exactly in the existing float32 table format.
ipc::ResultPayload revision{};
revision.processing_name = "processing_revision";
revision.kind = ipc::ResultKind::TableF32;
revision.table_columns = 4U;
for (unsigned shift = 0; shift < 64; shift += 16) {
revision.table_values.push_back(static_cast<float>((live_config.settings_revision >> shift) & 0xffffU));
}
result.collection_payloads.push_back(std::move(revision));
result.collection_id = preprocessed.collection_id;
result.monotonic_ns = ipc::current_monotonic_ns();
result.settings_revision = live_config.settings_revision;
result.capture_start_ns = preprocessed.capture_start_ns;
result.capture_end_ns = preprocessed.capture_end_ns;
// Socket speed can change without a GUI revision; retain the actual value per result.
result.speed_m_s = live_config.gpr_speed_m_s;
return result;
}
@@ -268,7 +257,7 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config
filter.draw_limits.reset();
}
// Coherent BP already emits the FINAL visible object set from the processor
// (window + N/M, no score threshold — matching Horns_motion_3libre.py), so the
// (window + N/M, no score threshold), so the
// socket forwards it verbatim. The default FilterParams passes everything through
// (it only drops non-finite rows), keeping the filtering logic in one place.
return filter;
@@ -1,43 +1,14 @@
#include <atomic>
#include <csignal>
#include <exception>
#include <iostream>
#include <memory>
#include <string>
#include "data_processor.hpp"
#include "locator/tcp_server.hpp"
#include "run_config.hpp"
#include "pipeline_runtime.hpp"
#include "shm_ring.hpp"
namespace {
constexpr const char* kDefaultConfigPath = "run_config.json";
std::atomic<bool> g_stop_requested{false};
void signal_handler(int /*signal*/) {
g_stop_requested.store(true, std::memory_order_relaxed);
}
void install_signal_handlers() {
std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, signal_handler);
// Writing to a peer-closed socket would otherwise terminate the process.
std::signal(SIGPIPE, SIG_IGN);
}
[[nodiscard]] auto read_config_path(int argc, char** argv) -> std::string {
std::string config_path = kDefaultConfigPath;
for (int index = 1; index < argc; ++index) {
const std::string arg = argv[index];
if (arg == "--config" && (index + 1) < argc) {
config_path = argv[++index];
}
}
return config_path;
}
[[nodiscard]] auto start_locator_server(const radar::config::RunConfig& config)
-> std::unique_ptr<radar::locator::TcpServer> {
if (!config.locator_server.enabled) {
@@ -56,11 +27,8 @@ void install_signal_handlers() {
} // namespace
int main(int argc, char** argv) {
install_signal_handlers();
try {
const auto config_path = read_config_path(argc, argv);
const auto config = radar::config::load_run_config(config_path);
const auto config = radar::runtime::initialize(argc, argv);
auto preprocessed_ring = radar::ipc::ShmRing::open_or_create(
config.rings.preprocessed.name,
@@ -82,7 +50,7 @@ int main(int argc, char** argv) {
radar::processing::create_default_processors(),
locator_server.get()
);
processor.run(g_stop_requested);
processor.run(radar::runtime::stop_requested);
return 0;
} catch (const std::exception& exception) {
std::cerr << "data_processor error: " << exception.what() << '\n';
@@ -19,19 +19,6 @@ namespace {
using Json = nlohmann::json;
[[nodiscard]] auto parse_history_command(const std::string& value) -> HistoryCommand {
if (value == "none") {
return HistoryCommand::None;
}
if (value == "remove_last") {
return HistoryCommand::RemoveLast;
}
if (value == "clear_all") {
return HistoryCommand::ClearAll;
}
throw std::runtime_error("processing.history_command must be one of: none, remove_last, clear_all");
}
[[nodiscard]] auto parse_legacy_gpr_mode(const std::string& value, const std::string& field_name) -> LegacyGprMode {
if (value == "point") {
return LegacyGprMode::Point;
@@ -86,13 +73,6 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
}
[[nodiscard]] auto parse_s_parameter_channel(const std::string& value, const std::string& field_name) -> std::string {
if (value == "s21" || value == "s11") {
return value;
}
throw std::runtime_error(field_name + " must be one of: s21, s11");
}
[[nodiscard]] auto parse_f32(const Json& value, const std::string& field_name) -> float {
if (!value.is_number()) {
throw std::runtime_error(field_name + " must be number");
@@ -226,23 +206,7 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
config::validate_gpr_config(*config.gpr, run_config);
}
assign_optional(root, "processor_mode", config.processor_mode);
if (const auto found = root.find("pass_through_channel"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.pass_through_channel must be string");
}
config.pass_through_channel =
parse_s_parameter_channel(found->get<std::string>(), "processing.pass_through_channel");
}
assign_optional(root, "pass_through_fixed_y_enabled", config.pass_through_fixed_y_enabled);
assign_optional(root, "pass_through_y_min_db", config.pass_through_y_min_db);
assign_optional(root, "pass_through_y_max_db", config.pass_through_y_max_db);
assign_optional(root, "bscan_axis", config.bscan_axis);
if (const auto found = root.find("bscan_channel"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.bscan_channel must be string");
}
config.bscan_channel = parse_s_parameter_channel(found->get<std::string>(), "processing.bscan_channel");
}
assign_optional(root, "bscan_cut_m", config.bscan_cut_m);
assign_optional(root, "bscan_max_depth_m", config.bscan_max_depth_m);
assign_optional(root, "bscan_gain", config.bscan_gain);
@@ -344,14 +308,23 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
}
assign_optional(root, "ignore_socket_speed", config.ignore_socket_speed);
assign_optional(root, "reprocess_current_result", config.reprocess_current_result);
if (const auto found = root.find("history_command_seq"); found != root.end()) {
config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq");
if (const auto found = root.find("history_clear_before_ns"); found != root.end()) {
config.history_clear_before_ns = parse_u64_number(*found, "processing.history_clear_before_ns");
}
if (const auto found = root.find("history_command"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.history_command must be string");
if (const auto found = root.find("history_removed"); found != root.end()) {
if (!found->is_array()) {
throw std::runtime_error("processing.history_removed must be an array");
}
for (const auto& value : *found) {
HistoryTarget target{
parse_u64_number(value.at("collection_id"), "history_removed.collection_id"),
parse_u64_number(value.at("capture_start_ns"), "history_removed.capture_start_ns"),
};
if (!target.capture_start_ns) {
throw std::runtime_error("history_removed.capture_start_ns must be positive");
}
config.history_removed.push_back(target);
}
config.history_command = parse_history_command(found->get<std::string>());
}
return config;
}
@@ -393,9 +366,10 @@ auto ProcessingLiveConfigLoader::refresh_if_needed() -> ProcessingLiveConfig {
return current_;
}
current_ = parse_live_config(json_text, path_, run_config_);
// Remember rejected content too; retry only when the file actually changes.
last_json_text_ = json_text;
has_last_json_text_ = true;
current_ = parse_live_config(json_text, path_, run_config_);
++revision_;
} catch (const std::exception& error) {
std::cerr << "data_processor warning: failed to refresh live processing config: " << error.what() << '\n';
@@ -36,7 +36,6 @@ class ClientQueue {
// Wake any waiter and reject further pushes. Idempotent.
void close();
[[nodiscard]] auto is_closed() const -> bool;
private:
mutable std::mutex mutex_{};
@@ -197,11 +197,6 @@ void ClientQueue::close() {
not_empty_.notify_all();
}
auto ClientQueue::is_closed() const -> bool {
std::lock_guard<std::mutex> guard(mutex_);
return closed_;
}
// ----- ClientSession --------------------------------------------------------
ClientSession::ClientSession(
@@ -10,8 +10,7 @@
namespace radar::processing {
// Temporal "approach" filter for coherent-BP detections — a port of the objects-only
// filter in Horns_motion_3libre's demonstrate notebook.
// Temporal "approach" filter for coherent-BP detections.
//
// It keeps only objects that persist as a *motion-consistent track* across at least
// `min_frames` consecutive frames: as the radar moves, a real target reappears at a
@@ -30,12 +29,8 @@ namespace radar::processing {
// inter-frame gap (`kMaxFrameGapSeconds`), so a stale history from a previous run cannot
// spuriously confirm objects.
//
// IDEMPOTENT under reprocessing: the data_processor re-runs the last collection (or
// replays the whole window) whenever live settings or the socket speed change, with no
// new sweep. The history is therefore keyed by `frame_id` (the strictly increasing
// collection id): the same id replaces its entry (reprocess of the current frame), a
// smaller id rebuilds from scratch (a replay restart), so repeated reprocessing never
// duplicates frames or falsely confirms a track.
// Reprocessing replaces the same (collection_id, capture_start_ns) entry. Earlier
// capture times rebuild the history when the processor replays its retained window.
class ObjectApproachFilter {
public:
struct Point {
@@ -43,20 +38,16 @@ class ObjectApproachFilter {
double z_m;
};
// Record `objects` as frame `frame_id` and return, per object, whether it is
// confirmed (ends a >= `min_frames` motion-consistent track). `frame_id` is the
// collection id (identity/order, survives reprocessing); `frame_time_seconds` is the
// frame's wall-clock timestamp (drives the inter-frame interval); `speed_m_s`/
// `look_angle_deg` are the live motion estimate. `min_frames <= 1` disables filtering.
// Acquisition time drives both ordering and motion intervals. min_frames <= 1 disables filtering.
[[nodiscard]] auto confirm(
const std::vector<Point>& objects,
std::uint64_t frame_id,
double frame_time_seconds,
std::uint64_t capture_start_ns,
double speed_m_s,
double look_angle_deg,
std::size_t min_frames
) -> std::vector<bool> {
record_frame(Frame{frame_id, frame_time_seconds, objects});
record_frame(Frame{frame_id, capture_start_ns, objects});
const std::size_t history_depth = std::max<std::size_t>(min_frames, 1U);
while (history_.size() > history_depth) {
history_.pop_front();
@@ -79,17 +70,14 @@ class ObjectApproachFilter {
private:
struct Frame {
std::uint64_t id;
double time_seconds;
std::uint64_t capture_start_ns;
std::vector<Point> objects;
};
// Append a genuinely new frame, replace the current one on reprocessing (same id),
// or rebuild from scratch when the id steps backward (a replay restart). This keeps
// the history one entry per distinct collection no matter how often settings change.
void record_frame(Frame frame) {
if (history_.empty() || frame.id > history_.back().id) {
if (history_.empty() || frame.capture_start_ns > history_.back().capture_start_ns) {
history_.push_back(std::move(frame));
} else if (frame.id == history_.back().id) {
} else if (frame.id == history_.back().id && frame.capture_start_ns == history_.back().capture_start_ns) {
history_.back() = std::move(frame);
} else {
history_.clear();
@@ -97,7 +85,7 @@ class ObjectApproachFilter {
}
}
// Fixed matching tolerances (Horns_motion notebook 0.2 block). Range decreases as the
// Fixed matching tolerances. Range decreases as the
// radar approaches the target, hence the negative Z sign.
static constexpr double kXToleranceM = 0.45;
static constexpr double kRangeToleranceFraction = 0.85;
@@ -122,8 +110,10 @@ class ObjectApproachFilter {
for (std::size_t step = 1U; step < min_frames; ++step) {
const std::size_t earlier_index = newest - step;
const Frame& earlier = history_[earlier_index];
const double dt = history_[earlier_index + 1U].time_seconds - earlier.time_seconds;
if (!(dt > 0.0) || dt > kMaxFrameGapSeconds) {
const auto later_ns = history_[earlier_index + 1U].capture_start_ns;
if (later_ns <= earlier.capture_start_ns) { return false; }
const double dt = static_cast<double>(later_ns - earlier.capture_start_ns) * 1e-9;
if (dt > kMaxFrameGapSeconds) {
return false; // non-monotonic time, or a gap that breaks the track
}
const double expected_range_shift = range_step_per_second * dt;
@@ -93,16 +93,6 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
return std::abs(sample);
}
[[nodiscard]] auto selected_trace_samples(
const ipc::SweepTraceBlock& trace,
const ProcessingLiveConfig& live_config
) -> std::span<const ipc::Complex32> {
if (live_config.bscan_channel == "s11") {
return trace.s11;
}
return trace.s21;
}
[[nodiscard]] auto fallback_profile(
const ipc::SweepTraceBlock& trace,
std::span<const ipc::Complex32> selected_samples
@@ -125,7 +115,7 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
const ipc::SweepTraceBlock& trace,
const ProcessingLiveConfig& live_config
) -> BScanProfile {
const auto selected_samples = selected_trace_samples(trace, live_config);
const auto selected_samples = std::span<const ipc::Complex32>(trace.s21);
const std::size_t point_count = std::min(trace.frequency_hz.size(), selected_samples.size());
if (point_count < 2U) {
return fallback_profile(trace, selected_samples);
@@ -237,8 +227,6 @@ auto BScanProcessor::process_collection(
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
results.blocks.reserve(collection.traces.size());
for (const auto& trace : collection.traces) {
@@ -51,7 +51,7 @@ constexpr double kScoreCfEps = 1e-12;
using PairKey = std::uint64_t;
// Frequency-domain speed correction mode (Horns_motion_3libre.py MOTION_CONFIG):
// Frequency-domain speed correction mode:
// IntMinus — full intra-frequency correction to the interleaved-sweep centre.
// IntFocus — remove the constant + linear-in-frequency part of phi(f), leaving
// the focusing residual without a net Z shift.
@@ -653,9 +653,7 @@ void validate_collection_trace_order(
return traces;
}
// Frequency-domain speed correction applied to S21 *before* the IFFT, ported
// from Horns_motion_3libre.py (apply_intra_sweep_phase_correction +
// compute_frequency_sample_times). The measurement is interleaved Tx-by-frequency
// Frequency-domain speed correction applied to S21 *before* the IFFT. The measurement is interleaved Tx-by-frequency
// — Tx0(f0), Tx1(f0), Tx0(f1), ... — so each Tx occupies one interleave slot and
// its frequency k is sampled at global step (slot_count*k + slot). Each in-band
// frequency's phase is rotated back to the centre of the (full) interleaved sweep.
@@ -1740,7 +1738,7 @@ void add_bp_score_metrics(
}
}
// Select the FINAL visible objects exactly as Horns_motion_3libre.py does, so the
// Select the FINAL visible objects here, so the
// processor is the single source of truth: the GUI plot and the locator socket both
// consume this set verbatim (no second, duplicated filter). Steps, in order:
// 1. drop sidelobe candidates (when enabled), then sort by score (peak tie-break);
@@ -1844,8 +1842,6 @@ void add_bp_score_metrics(
ObjectApproachFilter& approach_filter
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
const auto selection = build_geometry_selection(run_config, live_config);
if (selection.input_positions.empty() || selection.output_positions.empty()) {
@@ -1859,9 +1855,8 @@ void add_bp_score_metrics(
return results;
}
// Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python
// reference Horns_motion_3libre.py (its 0.3 block hardcodes eps_r = 1.0). The
// configurable relative_permittivity stays a legacy-GPR-only knob.
// Coherent BP fixes the medium to vacuum/air (eps_r = 1); the configurable
// relative_permittivity is a legacy-GPR-only knob.
const double velocity_mps = kSpeedOfLightMetersPerSec;
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
@@ -1948,7 +1943,7 @@ void add_bp_score_metrics(
min_depth_m,
max_depth_m,
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0)
0.0 // angle compensation is fixed off for coherent BP
);
if (bp.image.empty()) {
return results;
@@ -1980,7 +1975,7 @@ void add_bp_score_metrics(
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map));
// Final visible set (window + N/M), then the cross-frame approach filter: keep only
// objects confirmed as a >= min_frames motion-consistent track (Horns_motion notebook).
// objects confirmed as a >= min_frames motion-consistent track.
const auto visible = select_visible_objects(objects, live_config);
std::vector<ObjectApproachFilter::Point> visible_points{};
visible_points.reserve(visible.size());
@@ -1990,7 +1985,7 @@ void add_bp_score_metrics(
const auto confirmed = approach_filter.confirm(
visible_points,
collection.collection_id,
static_cast<double>(collection.monotonic_ns) * 1e-9,
collection.capture_start_ns,
static_cast<double>(live_config.gpr_speed_m_s),
static_cast<double>(live_config.gpr_look_angle_deg),
static_cast<std::size_t>(live_config.gpr_object_approach_min_frames)
@@ -853,8 +853,6 @@ void apply_legacy_motion_correction(
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
const auto selection = build_geometry_selection(run_config, live_config);
if (selection.input_positions.empty() || selection.output_positions.empty()) {
@@ -881,7 +879,7 @@ void apply_legacy_motion_correction(
// Motion timing is computed once per collection. Matrix radars get one
// event per Tx (parallel Rx); sequential radars get one event per pair.
const bool matrix_radar = is_matrix_radar_model(run_config.radar.model);
const bool matrix_radar = is_matrix_radar_model(run_config.radar_model);
const auto motion_timing = compute_legacy_motion_timing(
selected_traces,
matrix_radar,
@@ -10,11 +10,9 @@ auto PassThroughProcessor::process_collection(
const config::RunConfig& /*run_config*/,
const ipc::PreprocessedCollection& collection,
std::span<const ipc::PreprocessedCollection> /*previous_collections*/,
const ProcessingLiveConfig& live_config
const ProcessingLiveConfig& /*live_config*/
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
results.blocks.reserve(collection.traces.size());
for (const auto& trace : collection.traces) {
@@ -22,11 +20,7 @@ auto PassThroughProcessor::process_collection(
payload.processing_name = name();
payload.kind = ipc::ResultKind::TraceComplex;
payload.frequency_hz = trace.frequency_hz;
if (live_config.pass_through_channel == "s11") {
payload.trace = trace.s11;
} else {
payload.trace = trace.s21;
}
payload.trace = trace.s21;
ipc::ResultBlock block{};
block.combo = trace.combo;
@@ -297,9 +297,8 @@ void check(nvrtcResult result, const char* what) {
}
}
if (target == 0) {
throw std::runtime_error(
"GPU sm_" + std::to_string(device_arch) + " is older than every architecture this NVRTC supports"
);
throw CudaUnavailable("CUDA GPU sm_" + std::to_string(device_arch) +
" is older than every architecture this NVRTC supports; rebuild with NVRTC 12");
}
const bool native = target == device_arch;
const std::string arch_option =
@@ -387,7 +386,7 @@ void launch(CUfunction kernel, int items, Args... args) {
} // namespace
struct CudaBackend {
struct Gpu::Impl {
// Declared first, destroyed last: buffers and the module need the context.
struct Context {
CUdevice device = 0;
@@ -395,10 +394,10 @@ struct CudaBackend {
Context() {
check(cuInit(0U), "init", true);
check(cuDeviceGet(&device, 0), "device", true);
check(cuDevicePrimaryCtxRetain(&handle, device), "context");
check(cuDevicePrimaryCtxRetain(&handle, device), "context", true);
if (const CUresult result = cuCtxSetCurrent(handle); result != CUDA_SUCCESS) {
cuDevicePrimaryCtxRelease(device);
check(result, "context");
check(result, "context", true);
}
}
~Context() { cuDevicePrimaryCtxRelease(device); }
@@ -430,8 +429,8 @@ struct CudaBackend {
DeviceBuffer images;
DeviceBuffer peak_stats;
~CudaBackend() { cuCtxSetCurrent(context.handle); }
explicit CudaBackend(std::span<const float> weights) {
~Impl() { cuCtxSetCurrent(context.handle); }
explicit Impl(std::span<const float> weights) {
smooth_radius = static_cast<int>(weights.size() / 2U);
taps.upload(weights);
}
@@ -439,13 +438,13 @@ struct CudaBackend {
auto project(const VolumeRequest&) -> Projections;
};
auto CudaBackend::inverse_dft(
auto Gpu::Impl::inverse_dft(
std::span<const std::complex<float>> bands,
std::size_t points,
std::size_t first_bin,
std::size_t size
) -> std::vector<std::complex<float>> {
CudaBackend& gpu = *this;
Impl& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const std::size_t samples = bands.size() / points * size;
const int items = checked_int(samples, "A-scan stack");
@@ -456,8 +455,8 @@ auto CudaBackend::inverse_dft(
return download<std::complex<float>>(output, samples);
}
auto CudaBackend::project(const VolumeRequest& request) -> Projections {
CudaBackend& gpu = *this;
auto Gpu::Impl::project(const VolumeRequest& request) -> Projections {
Impl& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const int nx = checked_int(request.x.size(), "grid");
@@ -524,17 +523,15 @@ auto CudaBackend::project(const VolumeRequest& request) -> Projections {
return result;
}
#else
struct Gpu::Impl {};
#endif
struct Engine::Impl {
Gpu::Gpu(double smooth_sigma_voxels) {
#ifdef SAR3D_WITH_CUDA
std::unique_ptr<CudaBackend> cuda;
#endif
};
Engine::Engine(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
#ifdef SAR3D_WITH_CUDA
impl_->cuda = std::make_unique<CudaBackend>(gaussian_taps(smooth_sigma_voxels));
impl_ = std::make_unique<Impl>(gaussian_taps(smooth_sigma_voxels));
#else
(void)smooth_sigma_voxels;
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA; this program was built without "
@@ -542,23 +539,24 @@ Engine::Engine(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
#endif
}
Engine::~Engine() = default;
Gpu::~Gpu() = default;
auto Engine::inverse_dft(std::span<const std::complex<float>> bands, std::size_t points,
std::size_t first_bin, std::size_t size) -> std::vector<std::complex<float>> {
// Without CUDA the constructor throws, so the #else branches below only satisfy the compiler.
auto Gpu::inverse_dft(std::span<const std::complex<float>> bands, std::size_t points,
std::size_t first_bin, std::size_t size) -> std::vector<std::complex<float>> {
if (points == 0U || bands.empty() || bands.size() % points != 0U || !std::has_single_bit(size) ||
size > kMaxIndexedItems || first_bin >= size || points > size - first_bin) {
throw std::runtime_error("sar3d invalid inverse FFT dimensions");
}
(void)checked_product(bands.size() / points, size, kMaxIndexedItems, "A-scan stack");
#ifdef SAR3D_WITH_CUDA
return impl_->cuda->inverse_dft(bands, points, first_bin, size);
return impl_->inverse_dft(bands, points, first_bin, size);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
}
auto Engine::project(const VolumeRequest& request) -> Projections {
auto Gpu::project(const VolumeRequest& request) -> Projections {
const auto nx = request.x.size(), ny = request.y.size(), nz = request.z.size();
const auto plane = checked_product(nx, nz, kMaxIndexedItems, "grid plane");
(void)checked_product(plane, ny, kMaxIndexedItems, "grid (32-bit indexing; increase SAR_VOXEL_M)");
@@ -581,11 +579,6 @@ auto Engine::project(const VolumeRequest& request) -> Projections {
}
}
}
for (const auto& sample : request.ascans) {
if (!std::isfinite(sample.real()) || !std::isfinite(sample.imag())) {
throw std::runtime_error("sar3d A-scan contains a non-finite sample");
}
}
for (const auto& term : request.contributions) {
if (!std::isfinite(term.weight) || term.weight < 0.0F ||
!std::isfinite(term.inv_reference_ranges) || term.inv_reference_ranges <= 0.0F) {
@@ -600,7 +593,7 @@ auto Engine::project(const VolumeRequest& request) -> Projections {
(void)checked_product(static_cast<std::size_t>(view.nu), static_cast<std::size_t>(view.nv),
kMaxIndexedItems, "oblique image");
#ifdef SAR3D_WITH_CUDA
return impl_->cuda->project(request);
return impl_->project(request);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
@@ -8,7 +8,7 @@
#include <stdexcept>
#include <vector>
// Numerical backend of the sar3d processor: A-scans of the band spectra, and the
// CUDA backend of the sar3d processor: A-scans of the band spectra, and the
// backprojection of an aperture into the 3D volume, smoothed and projected.
// Only the projections and peak leave the backend.
namespace radar::processing::sar3d {
@@ -74,12 +74,12 @@ struct Projections {
// CUDA context and runtime-compiled kernels. A missing device raises
// CudaUnavailable; computation and compilation failures remain ordinary errors.
class Engine {
class Gpu {
public:
explicit Engine(double smooth_sigma_voxels);
~Engine();
Engine(const Engine&) = delete;
auto operator=(const Engine&) -> Engine& = delete;
explicit Gpu(double smooth_sigma_voxels);
~Gpu();
Gpu(const Gpu&) = delete;
auto operator=(const Gpu&) -> Gpu& = delete;
// numpy.fft.ifft of each band spectrum placed at bins [first_bin, first_bin +
// points) of a `size`-point spectrum; `bands` holds the bands back to back.
@@ -41,10 +41,10 @@ constexpr float kCompRangeWeightMax = 5.0F; // COMP_RANGE_WEIGHT_MAX
constexpr double kEdgeFrameWeight = 0.8; // SAR_EDGE_FRAME_WEIGHT
constexpr double kSmoothSigma = 1.5; // BP_SMOOTH_SIGMA, voxels
constexpr double kNormEdgeMargin = 0.3; // SAR_NORM_EDGE_MARGIN_M
// Scene window: X across, Y height above the ground (SAR_Y_RANGE_M). Z along the
// ground follows the depth gate.
constexpr double kSceneXMin = -2.0;
constexpr double kSceneXMax = 2.0;
// Scene window: X across spans the selected antennas plus a margin on each side
// (x_ant.min() - 2 ... x_ant.max() + 2), Y is the height above the ground
// (SAR_Y_RANGE_M), Z along the ground follows the depth gate.
constexpr double kSceneXMargin = 2.0;
constexpr double kSceneYMin = -0.5;
constexpr double kSceneYMax = 2.0;
static_assert(kSceneYMax - kSceneYMin > 2.5 * kNormEdgeMargin, "else sar_norm_reference_mask keeps every plane");
@@ -169,8 +169,7 @@ struct TraceClock {
};
struct CollectionTiming {
std::uint64_t center_ns = 0U; // integer centre avoids overflow and loss of relative timing
double center_fraction_ns = 0.0;
std::uint64_t center_ns = 0U; // centre of the collection's capture window
std::vector<TraceClock> clocks; // per collection trace
};
@@ -202,7 +201,6 @@ struct CollectionTiming {
CollectionTiming timing{};
const auto duration_ns = last - first;
timing.center_ns = first + duration_ns / 2U;
timing.center_fraction_ns = 0.5 * static_cast<double>(duration_ns % 2U);
for (const auto& trace : collection.traces) {
const auto& stages = sweep_outputs.at({trace.capture_start_ns, trace.capture_end_ns});
const auto stage = std::find(stages.begin(), stages.end(), trace.combo.output_pos) - stages.begin();
@@ -315,7 +313,6 @@ void normalize_ascan(std::span<std::complex<float>> ascan, double dt_s, const Se
// A-scans of every selected Tx/Rx pair of one collection, (Tx, Rx) order.
struct Frame {
std::uint64_t center_ns = 0U;
double center_fraction_ns = 0.0;
double dt_s = 0.0;
std::size_t samples = 0U; // per A-scan
std::vector<std::complex<float>> ascans{}; // [pair][sample]
@@ -324,7 +321,7 @@ struct Frame {
[[nodiscard]] auto compute_frame(
const ipc::PreprocessedCollection& collection,
const Settings& settings,
sar3d::Engine& backend
sar3d::Gpu& backend
) -> Frame {
const auto timing = collection_timing(collection);
std::vector<Band> bands{};
@@ -354,7 +351,6 @@ struct Frame {
}
Frame frame{};
frame.center_ns = timing.center_ns;
frame.center_fraction_ns = timing.center_fraction_ns;
frame.samples = first.size;
frame.dt_s = 1.0 / (static_cast<double>(first.size) * first.df_hz);
frame.ascans = backend.inverse_dft(spectra, first.spectrum.size(), first.first_bin, first.size);
@@ -399,6 +395,15 @@ struct Grid {
if (std::abs(cos_p) < 1e-6) {
throw std::runtime_error("SAR grid is undefined at a radar look angle of 90 degrees");
}
// The pitch turns the radar about X, so antenna X is already the scene X.
double x_min = std::numeric_limits<double>::infinity();
double x_max = -std::numeric_limits<double>::infinity();
for (const auto* antennas : {&settings.tx, &settings.rx}) {
for (const auto& antenna : *antennas) {
x_min = std::min(x_min, antenna[0] - kSceneXMargin);
x_max = std::max(x_max, antenna[0] + kSceneXMargin);
}
}
double z_min = std::numeric_limits<double>::infinity();
double z_max = -std::numeric_limits<double>::infinity();
for (const double height : {kSceneYMin, kSceneYMax}) {
@@ -414,13 +419,13 @@ struct Grid {
const auto axis_size = [&](double span) {
return checked_axis_size(std::max(std::nearbyint(span / settings.voxel_m) + 1.0, 2.0));
};
const auto nx = axis_size(kSceneXMax - kSceneXMin);
const auto nx = axis_size(x_max - x_min);
const auto ny = axis_size(kSceneYMax - kSceneYMin);
const auto nz = axis_size(z_max - z_min);
if (nx > static_cast<std::size_t>(std::numeric_limits<int>::max()) / ny / nz) {
throw std::runtime_error("SAR grid exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
}
return {linspace(kSceneXMin, kSceneXMax, nx), linspace(kSceneYMin, kSceneYMax, ny),
return {linspace(x_min, x_max, nx), linspace(kSceneYMin, kSceneYMax, ny),
linspace(z_min, z_max, nz)};
}
@@ -571,11 +576,11 @@ struct Oblique {
} // namespace
struct Sar3dProcessor::State {
// A sweep is its id with its timestamp: ids start again from 1 on every run,
// Acquisition identity is (id, capture start): ids restart on every run,
// while the processor and this cache outlive runs.
using SweepKey = std::pair<std::uint64_t, std::uint64_t>;
[[nodiscard]] static auto sweep_key(const ipc::PreprocessedCollection& collection) -> SweepKey {
return {collection.collection_id, collection.monotonic_ns};
return {collection.collection_id, collection.capture_start_ns};
}
struct CachedFrame {
@@ -584,12 +589,12 @@ struct Sar3dProcessor::State {
};
std::map<SweepKey, CachedFrame> frames{};
AscanKey frames_key{};
std::unique_ptr<sar3d::Engine> engine{};
std::exception_ptr engine_error{};
std::unique_ptr<sar3d::Gpu> device{};
std::exception_ptr device_error{}; // why the GPU could not be opened; not retried
std::string last_error{};
std::uint64_t repeated_errors = 0U;
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Engine& backend)
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Gpu& backend)
-> std::shared_ptr<const Frame> {
if (const auto found = frames.find(sweep_key(collection)); found != frames.end()) {
if (found->second.value == nullptr) { report_error(found->second.error); }
@@ -607,13 +612,13 @@ struct Sar3dProcessor::State {
return frame;
}
auto numerical_engine() -> sar3d::Engine& {
if (engine == nullptr && engine_error == nullptr) {
try { engine = std::make_unique<sar3d::Engine>(kSmoothSigma); }
catch (...) { engine_error = std::current_exception(); }
auto gpu() -> sar3d::Gpu& {
if (device == nullptr && device_error == nullptr) {
try { device = std::make_unique<sar3d::Gpu>(kSmoothSigma); }
catch (...) { device_error = std::current_exception(); }
}
if (engine == nullptr) { std::rethrow_exception(engine_error); }
return *engine;
if (device == nullptr) { std::rethrow_exception(device_error); }
return *device;
}
auto render(
@@ -621,14 +626,12 @@ struct Sar3dProcessor::State {
std::span<const ipc::PreprocessedCollection> previous,
const Settings& settings
) -> std::vector<ipc::ResultPayload> {
auto& backend = numerical_engine();
auto& backend = gpu();
if (settings.ascan_key() != frames_key) {
frames.clear();
frames_key = settings.ascan_key();
}
const auto history_count = settings.frame_radius >= previous.size() / 2U + previous.size() % 2U
? previous.size() : 2U * settings.frame_radius;
const auto recent = previous.last(history_count);
const auto recent = previous.last(std::min(previous.size(), 2U * settings.frame_radius));
std::set<SweepKey> retained{sweep_key(collection)};
for (const auto& member : recent) { retained.insert(sweep_key(member)); }
// Prune even when the newest frame is invalid, so a bad input stream
@@ -657,10 +660,9 @@ struct Sar3dProcessor::State {
std::vector<double> offsets_s{};
for (const auto& member : aperture) {
const double delta_ns = member->center_ns >= centre.center_ns
? static_cast<double>(member->center_ns - centre.center_ns)
: -static_cast<double>(centre.center_ns - member->center_ns);
offsets_s.push_back(1e-9 * (delta_ns + member->center_fraction_ns - centre.center_fraction_ns));
// Unsigned difference reinterpreted as signed: exact for any two timestamps.
const auto delta_ns = static_cast<std::int64_t>(member->center_ns - centre.center_ns);
offsets_s.push_back(1e-9 * static_cast<double>(delta_ns));
}
const auto weights = frame_weights(offsets_s);
const Grid grid = scene_grid(settings);
@@ -797,11 +799,18 @@ auto Sar3dProcessor::process_collection(
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
try {
results.collection_payloads =
state_->render(collection, previous_collections, read_settings(run_config, live_config));
// RSL3 includes the typed metadata in its fixed header.
const auto bytes = ipc::serialize_result_collection(results).size();
const std::size_t slot = run_config.rings.results.slot_size_bytes;
if (bytes > slot) {
throw std::runtime_error("the views need " + std::to_string(bytes / 1024U) +
" KiB but a results ring slot holds " + std::to_string(slot / 1024U) +
" KiB; increase SAR_VOXEL_M, narrow the depth range or raise "
"rings.results.slot_size_bytes");
}
} catch (const sar3d::CudaUnavailable& error) {
state_->report_error(error.what());
results.collection_payloads = {status_payload(3.0F, error.what())};
@@ -1,40 +0,0 @@
#pragma once
#include <vector>
#include "shared_types.hpp"
namespace radar::drivers {
/**
* @brief One forward sweep acquired from the radar.
*
* All vectors must have equal size and aligned indices.
*/
struct SweepTrace {
std::vector<float> frequency_hz{};
std::vector<ipc::Complex32> s11{};
std::vector<ipc::Complex32> s21{};
};
/**
* @brief Minimal radar interface used by the sweep orchestrator.
*
* Implementations are expected to be lightweight: configuration is handled by
* the Python layer, while this interface only opens/closes and acquires one
* forward sweep.
*/
class RadarDriver {
public:
virtual ~RadarDriver() = default;
/** @brief Open underlying transport and prepare acquisition. */
virtual void open() = 0;
/** @brief Release all allocated resources. */
virtual void close() = 0;
/** @brief Acquire one sweep containing the forward traces exposed by the driver. */
[[nodiscard]] virtual auto acquire_sweep() -> SweepTrace = 0;
};
} // namespace radar::drivers
@@ -1,24 +0,0 @@
#pragma once
#include <cstdint>
namespace radar::drivers {
/**
* @brief Unified RF switch interface consumed by the orchestrator.
*/
class SwitchDriver {
public:
virtual ~SwitchDriver() = default;
/** @brief Open underlying transport and set initial switch state. */
virtual void open() = 0;
/** @brief Release driver resources. */
virtual void close() = 0;
/** @brief Number of selectable positions exposed by this switch. */
[[nodiscard]] virtual auto position_count() const -> std::uint32_t = 0;
/** @brief Switch to a 0-based position. */
virtual void switch_to(std::uint32_t position) = 0;
};
} // namespace radar::drivers
@@ -1,241 +0,0 @@
#include "../librevna_minimal_driver.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <exception>
#include <string>
#include <string_view>
#include <stdexcept>
#include <utility>
#include <vector>
#include "librevna_protocol_common.hpp"
namespace radar::drivers {
namespace detail = radar::drivers::librevna::detail;
namespace {
constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
// Derive the budget the whole multi-point sweep is allowed to take before the
// first datapoint must arrive (#7): a fixed setup cost plus the expected dwell
// (points / IFBW) with a generous margin, clamped to the overall hard cap. The
// per-gap stall timeout then governs progress once datapoints start flowing.
[[nodiscard]] auto native_initial_sweep_budget(const config::RadarSweepSettings& sweep)
-> std::chrono::milliseconds {
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 dwell_ms =
(static_cast<double>(points) / static_cast<double>(if_bw)) * 1000.0 *
static_cast<double>(detail::kNativeSweepDwellMargin);
const double total_ms = static_cast<double>(detail::kNativeSweepSetupBudgetMs) + dwell_ms;
const auto budget = std::chrono::milliseconds(static_cast<std::int64_t>(total_ms));
return std::clamp<std::chrono::milliseconds>(
budget,
std::chrono::milliseconds(detail::kNativeSweepPerGapTimeoutMs),
std::chrono::milliseconds(detail::kNativeSweepHardCapMs)
);
}
[[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) {
return false;
}
constexpr std::array<std::string_view, 8> kRetryableSubstrings = {
"Timeout waiting for expected LibreVNA packet type",
"Timeout waiting for LibreVNA ACK",
"LibreVNA returned NACK",
"Failed to read LibreVNA USB bulk packet",
"Failed to write LibreVNA USB bulk packet",
// #31: a transient USB error often triggers a brief device re-enumeration,
// so the device may be momentarily absent or its handle invalidated when
// we reconnect. Treat these as retryable; the reconnect path absorbs the
// re-enumeration with a short bounded discovery backoff.
"No compatible LibreVNA USB device found",
"LibreVNA native handle is not open",
"LibreVNA USB handle is not open",
};
return std::any_of(
kRetryableSubstrings.begin(),
kRetryableSubstrings.end(),
[message](std::string_view needle) {
return message.find(needle) != std::string_view::npos;
}
);
}
} // namespace
LibreVnaMinimalDriver::LibreVnaMinimalDriver(LibreVnaMinimalDriverSettings settings) : settings_(std::move(settings)) {}
void LibreVnaMinimalDriver::open() {
if (is_open_) {
return;
}
if (settings_.sweep.points == 0U) {
throw std::runtime_error("Radar sweep points must be > 0");
}
open_native();
is_open_ = true;
}
void LibreVnaMinimalDriver::close() {
if (!is_open_) {
return;
}
close_native();
is_open_ = false;
}
auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
if (!is_open_) {
throw std::runtime_error("Radar driver is not open");
}
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();
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 (last_exception != nullptr) {
std::rethrow_exception(last_exception);
}
throw std::runtime_error("Native acquisition failed without exception detail");
}
auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
if (usb_handle_ == nullptr) {
throw std::runtime_error("LibreVNA native handle is not open");
}
// Drop stale datapoints left in queue by previous operations.
while (pop_packet(detail::kPacketVnaDatapoint).has_value()) {
}
send_packet_no_payload(detail::kPacketInitiateSweep, true);
SweepTrace trace{};
trace.frequency_hz.assign(settings_.sweep.points, 0.0F);
trace.s11.assign(settings_.sweep.points, ipc::Complex32{});
trace.s21.assign(settings_.sweep.points, ipc::Complex32{});
std::vector<std::uint8_t> received(settings_.sweep.points, 0U);
std::uint32_t received_count = 0;
// #7: the per-datapoint deadline is rebased every time a fresh point lands
// (or the first point must arrive within the size-derived budget), so a
// large sweep is not killed by one fixed ~1.5s timeout. An independent hard
// cap bounds the whole sweep against a device that streams forever.
const auto now = std::chrono::steady_clock::now();
const auto hard_cap_deadline = now + std::chrono::milliseconds(detail::kNativeSweepHardCapMs);
auto gap_deadline = std::min(now + native_initial_sweep_budget(settings_.sweep), hard_cap_deadline);
while (received_count < settings_.sweep.points) {
// #26: honour a stop request observed during the (chunked) USB wait so
// SIGTERM aborts the sweep promptly instead of after the full budget.
if (detail::native_stop_requested()) {
throw std::runtime_error("Native sweep aborted by stop request");
}
if (std::chrono::steady_clock::now() >= hard_cap_deadline) {
throw std::runtime_error(
"Native sweep exceeded hard cap (" + std::to_string(received_count) + "/" +
std::to_string(settings_.sweep.points) + " points received)"
);
}
const auto wait_deadline = std::min(gap_deadline, hard_cap_deadline);
NativePacket packet{};
try {
packet = wait_for_packet(detail::kPacketVnaDatapoint, wait_deadline);
} catch (const std::exception& exception) {
throw std::runtime_error(
"Timeout while collecting VNADatapoints (" + std::to_string(received_count) + "/" +
std::to_string(settings_.sweep.points) + " points received): " + exception.what()
);
}
DecodedVnaDatapoint datapoint{};
if (!decode_vna_datapoint_traces(packet.payload, datapoint)) {
throw std::runtime_error("Failed to decode traces from VNADatapoint packet");
}
if (datapoint.point_number >= settings_.sweep.points) {
throw std::runtime_error("Received out-of-range VNADatapoint index");
}
if (received[datapoint.point_number] == 0U) {
received[datapoint.point_number] = 1U;
++received_count;
}
trace.frequency_hz[datapoint.point_number] = datapoint.frequency_hz;
trace.s11[datapoint.point_number] = datapoint.s11;
trace.s21[datapoint.point_number] = datapoint.s21;
// Extend the per-gap stall timeout now that progress was made.
gap_deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(detail::kNativeSweepPerGapTimeoutMs);
}
return trace;
}
void LibreVnaMinimalDriver::validate_device_info_payload(std::span<const std::uint8_t> payload) const {
if (payload.size() != detail::kDeviceInfoPayloadSize) {
throw std::runtime_error("Unexpected DeviceInfo payload size");
}
const auto protocol = detail::read_u16_le(payload, detail::kDeviceInfoProtocolOffset);
if (protocol != detail::kProtocolV14) {
throw std::runtime_error("Unsupported LibreVNA protocol version (expected v14)");
}
const auto ports = payload[detail::kDeviceInfoNumPortsOffset];
if (ports < 2U) {
throw std::runtime_error("LibreVNA reported invalid number of ports");
}
}
} // namespace radar::drivers
@@ -1,148 +0,0 @@
#include "../librevna_minimal_driver.hpp"
#include <array>
#include <complex>
#include <cstddef>
#include <stdexcept>
#include <vector>
#include "librevna_protocol_common.hpp"
namespace radar::drivers {
namespace detail = radar::drivers::librevna::detail;
auto LibreVnaMinimalDriver::encode_frame(
std::uint8_t packet_type,
std::span<const std::uint8_t> payload
) -> std::vector<std::uint8_t> {
const auto length = payload.size() + detail::kFrameOverheadBytes;
if (length > 0xFFFFU) {
throw std::runtime_error("Protocol frame is too large");
}
std::vector<std::uint8_t> frame(length, 0);
frame[0] = detail::kFrameHeader;
detail::write_u16_le(frame, 1, static_cast<std::uint16_t>(length));
frame[3] = packet_type;
std::copy(payload.begin(), payload.end(), frame.begin() + 4);
std::uint32_t crc = 0;
if (packet_type != detail::kPacketVnaDatapoint) {
crc = crc32(std::span<const std::uint8_t>(frame.data(), frame.size() - 4U));
}
detail::write_u32_le(frame, frame.size() - 4U, crc);
return frame;
}
auto LibreVnaMinimalDriver::decode_vna_datapoint_traces(
std::span<const std::uint8_t> payload,
DecodedVnaDatapoint& datapoint_out
) -> bool {
// VNADatapoint payload layout:
// [0..7]=freq_or_time, [8..9]=cdbm, [10..11]=point_number,
// followed by N tuples of {real:f32, imag:f32, flags:u8}.
if (payload.size() < 12U) {
return false;
}
const auto values_block = payload.size() - 12U;
if ((values_block % 9U) != 0U) {
return false;
}
const auto num_values = values_block / 9U;
if (num_values == 0U) {
return false;
}
datapoint_out = DecodedVnaDatapoint{};
datapoint_out.point_number = detail::read_u16_le(payload, 10);
datapoint_out.frequency_hz = static_cast<float>(detail::read_u64_le(payload, 0));
const auto real_offset = 12U;
const auto imag_offset = real_offset + (4U * num_values);
const auto flags_offset = imag_offset + (4U * num_values);
std::array<std::complex<float>, 8> ref_by_stage{};
std::array<std::complex<float>, 8> s11_measured_by_stage{};
std::array<std::complex<float>, 8> s21_measured_by_stage{};
std::array<bool, 8> has_ref{};
std::array<bool, 8> has_s11_measured{};
std::array<bool, 8> has_s21_measured{};
constexpr float kReferenceMagnitudeSquaredEpsilon = 1e-12F;
for (std::size_t index = 0; index < num_values; ++index) {
const auto flags = payload[flags_offset + index];
const auto stage = static_cast<std::size_t>(flags >> 5U);
if (stage >= ref_by_stage.size()) {
continue;
}
const auto value = std::complex<float>(
detail::read_f32_le(payload, real_offset + (4U * index)),
detail::read_f32_le(payload, imag_offset + (4U * index))
);
const bool is_reference = (flags & detail::kReferenceFlagMask) != 0U;
if ((flags & detail::kPort1Mask) != 0U && is_reference) {
ref_by_stage[stage] = value;
has_ref[stage] = true;
}
if ((flags & detail::kPort1Mask) != 0U && !is_reference) {
s11_measured_by_stage[stage] = value;
has_s11_measured[stage] = true;
}
if ((flags & detail::kPort2Mask) != 0U && !is_reference) {
s21_measured_by_stage[stage] = value;
has_s21_measured[stage] = true;
}
}
// For the forward sweep used in this project, port1 reference is the
// incident signal, port1 measured is reflection (S11 numerator), and
// port2 measured is transmission (S21 numerator).
for (std::size_t stage = 0; stage < ref_by_stage.size(); ++stage) {
if (!has_ref[stage]) {
continue;
}
if (std::norm(ref_by_stage[stage]) <= kReferenceMagnitudeSquaredEpsilon) {
continue;
}
if (has_s11_measured[stage]) {
const auto s11_ratio = s11_measured_by_stage[stage] / ref_by_stage[stage];
datapoint_out.s11.re = s11_ratio.real();
datapoint_out.s11.im = s11_ratio.imag();
datapoint_out.has_s11 = true;
}
if (has_s21_measured[stage]) {
const auto s21_ratio = s21_measured_by_stage[stage] / ref_by_stage[stage];
datapoint_out.s21.re = s21_ratio.real();
datapoint_out.s21.im = s21_ratio.imag();
datapoint_out.has_s21 = true;
}
if (datapoint_out.has_s21) {
return true;
}
}
return false;
}
auto LibreVnaMinimalDriver::crc32(std::span<const std::uint8_t> data) -> std::uint32_t {
std::uint32_t crc = 0xFFFFFFFFU;
for (const auto byte : data) {
crc ^= static_cast<std::uint32_t>(byte);
for (int bit = 0; bit < 8; ++bit) {
const auto lsb = crc & 1U;
crc >>= 1U;
if (lsb != 0U) {
crc ^= 0xEDB88320U;
}
}
}
return ~crc;
}
} // namespace radar::drivers
@@ -1,394 +0,0 @@
#include "../librevna_minimal_driver.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <fcntl.h>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "librevna_protocol_common.hpp"
namespace radar::drivers {
namespace detail = radar::drivers::librevna::detail;
namespace {
[[nodiscard]] auto libusb_error_message(const std::string& prefix, int status_code) -> std::string {
return prefix + ": " + libusb_error_name(status_code);
}
[[nodiscard]] auto is_supported_vid_pid(std::uint16_t vendor_id, std::uint16_t product_id) -> bool {
return std::any_of(
detail::kSupportedUsbIds.begin(),
detail::kSupportedUsbIds.end(),
[vendor_id, product_id](const auto& pair) {
return pair.first == vendor_id && pair.second == product_id;
}
);
}
[[nodiscard]] auto parse_serial_string(libusb_device_handle* handle, std::uint8_t serial_index) -> std::string {
if (handle == nullptr || serial_index == 0U) {
return {};
}
std::array<unsigned char, 256> serial_bytes{};
const auto length = libusb_get_string_descriptor_ascii(
handle,
serial_index,
serial_bytes.data(),
static_cast<int>(serial_bytes.size())
);
if (length <= 0) {
return {};
}
return std::string(
reinterpret_cast<const char*>(serial_bytes.data()),
reinterpret_cast<const char*>(serial_bytes.data() + length)
);
}
[[nodiscard]] auto find_matching_device_handle(
libusb_context* usb_context,
const std::string& expected_serial
) -> libusb_device_handle* {
libusb_device** devices = nullptr;
const auto device_count = libusb_get_device_list(usb_context, &devices);
if (device_count < 0) {
throw std::runtime_error(
libusb_error_message("Failed to enumerate USB devices", static_cast<int>(device_count))
);
}
libusb_device_handle* selected_handle = nullptr;
for (ssize_t index = 0; index < device_count; ++index) {
auto* device = devices[index];
if (device == nullptr) {
continue;
}
libusb_device_descriptor descriptor{};
const auto descriptor_status = libusb_get_device_descriptor(device, &descriptor);
if (descriptor_status != LIBUSB_SUCCESS) {
continue;
}
if (!is_supported_vid_pid(descriptor.idVendor, descriptor.idProduct)) {
continue;
}
libusb_device_handle* candidate_handle = nullptr;
if (libusb_open(device, &candidate_handle) != LIBUSB_SUCCESS || candidate_handle == nullptr) {
continue;
}
if (!expected_serial.empty()) {
const auto serial = parse_serial_string(candidate_handle, descriptor.iSerialNumber);
if (serial != expected_serial) {
libusb_close(candidate_handle);
continue;
}
}
selected_handle = candidate_handle;
break;
}
libusb_free_device_list(devices, 1);
return selected_handle;
}
} // namespace
void LibreVnaMinimalDriver::open_native() {
if (usb_handle_ != nullptr) {
throw std::runtime_error("LibreVNA native state is already initialized");
}
// #26: observe SIGINT/SIGTERM (chaining to main()'s handler) so the blocking
// USB loops can bail out promptly during shutdown. Safe to call repeatedly.
detail::install_native_stop_observer();
// #31: keep the libusb_context alive across reconnect retries. Only create a
// fresh context the first time; reconnect paths reuse the surviving context
// and merely reopen the handle, avoiding a full libusb teardown/re-init.
if (usb_context_ == nullptr) {
const auto init_status = libusb_init(&usb_context_);
if (init_status != LIBUSB_SUCCESS) {
usb_context_ = nullptr;
throw std::runtime_error(libusb_error_message("Failed to initialize libusb", init_status));
}
}
try {
// #31: retry discovery with a short bounded backoff so a brief device
// re-enumeration (common right after a transient USB error) is absorbed
// instead of surfacing as a hard "device not found".
libusb_device_handle* selected_handle = nullptr;
for (int attempt = 0; attempt < detail::kNativeDiscoveryRetryAttempts; ++attempt) {
if (detail::native_stop_requested()) {
break; // #26: abandon discovery promptly during shutdown.
}
selected_handle = find_matching_device_handle(usb_context_, settings_.serial);
if (selected_handle != nullptr) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(detail::kNativeDiscoveryRetryDelayMs));
}
if (selected_handle == nullptr) {
const auto serial_hint = settings_.serial.empty() ? std::string() : " for serial '" + settings_.serial + "'";
throw std::runtime_error("No compatible LibreVNA USB device found" + serial_hint);
}
usb_handle_ = selected_handle;
const auto auto_detach_status = libusb_set_auto_detach_kernel_driver(usb_handle_, 1);
if (auto_detach_status != LIBUSB_SUCCESS && auto_detach_status != LIBUSB_ERROR_NOT_SUPPORTED) {
throw std::runtime_error(
libusb_error_message("Failed to configure USB auto-detach kernel driver", auto_detach_status)
);
}
const auto claim_status = libusb_claim_interface(usb_handle_, detail::kUsbInterface);
if (claim_status != LIBUSB_SUCCESS) {
throw std::runtime_error(libusb_error_message("Failed to claim LibreVNA USB interface", claim_status));
}
interface_claimed_ = true;
rx_buffer_.clear();
packet_queue_.clear();
send_packet_no_payload(detail::kPacketRequestDeviceInfo, true);
const auto info_packet = wait_for_packet(
detail::kPacketDeviceInfo,
std::chrono::steady_clock::now() + std::chrono::seconds(detail::kDeviceInfoTimeoutSeconds)
);
validate_device_info_payload(info_packet.payload);
protocol_version_ = detail::read_u16_le(info_packet.payload, detail::kDeviceInfoProtocolOffset);
device_num_ports_ = info_packet.payload[detail::kDeviceInfoNumPortsOffset];
} catch (...) {
close_native();
throw;
}
}
void LibreVnaMinimalDriver::close_native() {
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;
}
if (usb_context_ != nullptr) {
libusb_exit(usb_context_);
usb_context_ = nullptr;
}
protocol_version_ = 0;
device_num_ports_ = 0;
}
void LibreVnaMinimalDriver::send_packet(
std::uint8_t packet_type,
std::span<const std::uint8_t> payload,
bool require_ack
) {
if (usb_handle_ == nullptr) {
throw std::runtime_error("Cannot send packet: LibreVNA USB handle is not open");
}
auto frame = encode_frame(packet_type, payload);
int transferred = 0;
const auto transfer_status = libusb_bulk_transfer(
usb_handle_,
detail::kEndpointOut,
reinterpret_cast<unsigned char*>(frame.data()),
static_cast<int>(frame.size()),
&transferred,
detail::kUsbWriteTimeoutMs
);
if (transfer_status != LIBUSB_SUCCESS) {
throw std::runtime_error(libusb_error_message("Failed to write LibreVNA USB bulk packet", transfer_status));
}
if (transferred != static_cast<int>(frame.size())) {
throw std::runtime_error("Incomplete LibreVNA USB bulk write");
}
if (require_ack) {
wait_for_ack(std::chrono::steady_clock::now() + std::chrono::milliseconds(detail::kAckTimeoutMs));
}
}
void LibreVnaMinimalDriver::send_packet_no_payload(std::uint8_t packet_type, bool require_ack) {
send_packet(packet_type, std::span<const std::uint8_t>{}, require_ack);
}
void LibreVnaMinimalDriver::wait_for_ack(std::chrono::steady_clock::time_point deadline) {
while (std::chrono::steady_clock::now() < deadline) {
// #26: bail out promptly on SIGTERM/SIGINT rather than blocking until the
// ACK deadline expires.
if (detail::native_stop_requested()) {
throw std::runtime_error("LibreVNA ACK wait aborted by stop request");
}
std::optional<NativePacket> ack_or_nack{};
for (auto iter = packet_queue_.begin(); iter != packet_queue_.end(); ++iter) {
if (iter->packet_type != detail::kPacketAck && iter->packet_type != detail::kPacketNack) {
continue;
}
ack_or_nack = std::move(*iter);
packet_queue_.erase(iter);
break;
}
if (ack_or_nack.has_value()) {
if (ack_or_nack->packet_type == detail::kPacketAck) {
return;
}
throw std::runtime_error("LibreVNA returned NACK");
}
pump_usb(deadline);
}
throw std::runtime_error("Timeout waiting for LibreVNA ACK");
}
auto LibreVnaMinimalDriver::wait_for_packet(
std::uint8_t expected_type,
std::chrono::steady_clock::time_point deadline
) -> NativePacket {
while (std::chrono::steady_clock::now() < deadline) {
if (auto packet = pop_packet(expected_type); packet.has_value()) {
return *packet;
}
// #26: re-check the stop flag between short USB polls so a shutdown is
// honoured within a few hundred ms even on a long sweep deadline.
if (detail::native_stop_requested()) {
throw std::runtime_error("LibreVNA packet wait aborted by stop request");
}
pump_usb(deadline);
}
throw std::runtime_error("Timeout waiting for expected LibreVNA packet type");
}
void LibreVnaMinimalDriver::pump_usb(std::chrono::steady_clock::time_point deadline) {
if (usb_handle_ == nullptr) {
throw std::runtime_error("Cannot read packets: LibreVNA USB handle is not open");
}
const auto now = std::chrono::steady_clock::now();
if (now >= deadline) {
return;
}
const auto remaining_ms = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count();
const auto timeout_ms = static_cast<unsigned int>(
std::clamp<std::int64_t>(remaining_ms, detail::kUsbReadPollMinTimeoutMs, detail::kUsbReadPollMaxTimeoutMs)
);
std::array<unsigned char, detail::kUsbReadChunkBytes> buffer{};
int transferred = 0;
const auto transfer_status = libusb_bulk_transfer(
usb_handle_,
detail::kEndpointIn,
buffer.data(),
static_cast<int>(buffer.size()),
&transferred,
timeout_ms
);
if (transfer_status == LIBUSB_ERROR_TIMEOUT) {
return;
}
if (transfer_status != LIBUSB_SUCCESS) {
throw std::runtime_error(libusb_error_message("Failed to read LibreVNA USB bulk packet", transfer_status));
}
if (transferred <= 0) {
return;
}
rx_buffer_.insert(rx_buffer_.end(), buffer.begin(), buffer.begin() + transferred);
decode_frames_from_buffer();
}
void LibreVnaMinimalDriver::decode_frames_from_buffer() {
while (true) {
const auto header_iter = std::find(rx_buffer_.begin(), rx_buffer_.end(), detail::kFrameHeader);
if (header_iter == rx_buffer_.end()) {
rx_buffer_.clear();
return;
}
if (header_iter != rx_buffer_.begin()) {
rx_buffer_.erase(rx_buffer_.begin(), header_iter);
}
if (rx_buffer_.size() < 4U) {
return;
}
const auto declared_length = detail::read_u16_le(rx_buffer_, 1);
if (declared_length < detail::kFrameOverheadBytes || declared_length > detail::kMaxFrameLengthBytes) {
rx_buffer_.erase(rx_buffer_.begin());
continue;
}
if (rx_buffer_.size() < declared_length) {
return;
}
const std::vector<std::uint8_t> frame(rx_buffer_.begin(), rx_buffer_.begin() + declared_length);
rx_buffer_.erase(rx_buffer_.begin(), rx_buffer_.begin() + declared_length);
const auto packet_type = frame[3];
const auto received_crc = detail::read_u32_le(frame, frame.size() - 4U);
if (packet_type == detail::kPacketVnaDatapoint) {
if (received_crc != 0U) {
throw std::runtime_error("Invalid VNADatapoint CRC (expected zero)");
}
} else {
const auto expected_crc = crc32(std::span<const std::uint8_t>(frame.data(), frame.size() - 4U));
if (received_crc != expected_crc) {
throw std::runtime_error("Invalid LibreVNA packet CRC");
}
}
NativePacket packet{};
packet.packet_type = packet_type;
packet.payload.assign(frame.begin() + 4, frame.end() - 4);
packet_queue_.push_back(std::move(packet));
}
}
auto LibreVnaMinimalDriver::pop_packet(std::uint8_t packet_type) -> std::optional<NativePacket> {
for (auto iter = packet_queue_.begin(); iter != packet_queue_.end(); ++iter) {
if (iter->packet_type != packet_type) {
continue;
}
auto packet = std::move(*iter);
packet_queue_.erase(iter);
return packet;
}
return std::nullopt;
}
} // namespace radar::drivers
@@ -1,197 +0,0 @@
#pragma once
#include <array>
#include <csignal>
#include <signal.h> // POSIX sigaction/NSIG for the chaining stop observer (#26).
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <stdexcept>
#include <utility>
namespace radar::drivers::librevna::detail {
constexpr float kPi = 3.14159265358979323846F;
// Frame format:
// [0]=header, [1..2]=length_le, [3]=packet_type, [...payload...], [crc32_le]
constexpr std::uint8_t kFrameHeader = 0x5A;
constexpr std::size_t kFrameOverheadBytes = 8;
constexpr std::size_t kMaxFrameLengthBytes = 4096;
constexpr unsigned char kEndpointOut = 0x01;
constexpr unsigned char kEndpointIn = 0x81;
constexpr int kUsbInterface = 0;
constexpr std::uint8_t kPacketDeviceInfo = 5;
constexpr std::uint8_t kPacketAck = 7;
constexpr std::uint8_t kPacketNack = 10;
constexpr std::uint8_t kPacketRequestDeviceInfo = 15;
constexpr std::uint8_t kPacketVnaDatapoint = 27;
constexpr std::uint8_t kPacketInitiateSweep = 32;
constexpr std::size_t kDeviceInfoPayloadSize = 57;
constexpr std::size_t kDeviceInfoProtocolOffset = 0;
constexpr std::size_t kDeviceInfoNumPortsOffset = 54;
constexpr std::uint16_t kProtocolV14 = 14;
constexpr std::uint8_t kReferenceFlagMask = 0x10;
constexpr std::uint8_t kPort1Mask = 0x01;
constexpr std::uint8_t kPort2Mask = 0x02;
constexpr std::array<std::pair<std::uint16_t, std::uint16_t>, 3> kSupportedUsbIds = {
std::pair<std::uint16_t, std::uint16_t>{0x0483, 0x564E},
std::pair<std::uint16_t, std::uint16_t>{0x0483, 0x4121},
std::pair<std::uint16_t, std::uint16_t>{0x1209, 0x4121},
};
constexpr int kUsbWriteTimeoutMs = 500;
constexpr int kAckTimeoutMs = 800;
constexpr int kDeviceInfoTimeoutSeconds = 2;
constexpr std::size_t kUsbReadChunkBytes = 16 * 1024;
constexpr int kUsbReadPollMinTimeoutMs = 1;
constexpr int kUsbReadPollMaxTimeoutMs = 50;
// Native sweep timing budget (#7). The native deadline is no longer a single
// fixed value for the whole sweep: instead it is derived from the sweep size
// (fixed setup cost + per-point dwell ≈ points/IFBW) and is extended every
// time a fresh datapoint arrives via a per-gap stall timeout, while an overall
// hard cap bounds a wedged device.
constexpr int kNativeSweepSetupBudgetMs = 500;
constexpr float kNativeSweepDwellMargin = 3.0F;
constexpr int kNativeSweepPerGapTimeoutMs = 1500;
constexpr int kNativeSweepHardCapMs = 60'000;
// Reconnect backoff (#31). After a transient error the device may re-enumerate;
// retry discovery a few times with a short bounded delay before giving up.
constexpr int kNativeDiscoveryRetryAttempts = 10;
constexpr int kNativeDiscoveryRetryDelayMs = 150;
[[nodiscard]] inline auto read_u16_le(std::span<const std::uint8_t> data, std::size_t offset) -> std::uint16_t {
if ((offset + sizeof(std::uint16_t)) > data.size()) {
throw std::runtime_error("Failed to decode uint16 from payload");
}
return static_cast<std::uint16_t>(data[offset]) |
(static_cast<std::uint16_t>(data[offset + 1]) << 8U);
}
[[nodiscard]] inline auto read_u32_le(std::span<const std::uint8_t> data, std::size_t offset) -> std::uint32_t {
if ((offset + sizeof(std::uint32_t)) > data.size()) {
throw std::runtime_error("Failed to decode uint32 from payload");
}
return static_cast<std::uint32_t>(data[offset]) |
(static_cast<std::uint32_t>(data[offset + 1]) << 8U) |
(static_cast<std::uint32_t>(data[offset + 2]) << 16U) |
(static_cast<std::uint32_t>(data[offset + 3]) << 24U);
}
[[nodiscard]] inline auto read_u64_le(std::span<const std::uint8_t> data, std::size_t offset) -> std::uint64_t {
if ((offset + sizeof(std::uint64_t)) > data.size()) {
throw std::runtime_error("Failed to decode uint64 from payload");
}
std::uint64_t value = 0;
for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
value |= static_cast<std::uint64_t>(data[offset + index]) << (index * 8U);
}
return value;
}
[[nodiscard]] inline auto read_f32_le(std::span<const std::uint8_t> data, std::size_t offset) -> float {
const auto raw = read_u32_le(data, offset);
float out = 0.0F;
std::memcpy(&out, &raw, sizeof(float));
return out;
}
inline void write_u16_le(std::span<std::uint8_t> data, std::size_t offset, std::uint16_t value) {
if ((offset + sizeof(std::uint16_t)) > data.size()) {
throw std::runtime_error("Failed to encode uint16 into frame");
}
data[offset] = static_cast<std::uint8_t>(value & 0xFFU);
data[offset + 1] = static_cast<std::uint8_t>((value >> 8U) & 0xFFU);
}
inline void write_u32_le(std::span<std::uint8_t> data, std::size_t offset, std::uint32_t value) {
if ((offset + sizeof(std::uint32_t)) > data.size()) {
throw std::runtime_error("Failed to encode uint32 into frame");
}
data[offset] = static_cast<std::uint8_t>(value & 0xFFU);
data[offset + 1] = static_cast<std::uint8_t>((value >> 8U) & 0xFFU);
data[offset + 2] = static_cast<std::uint8_t>((value >> 16U) & 0xFFU);
data[offset + 3] = static_cast<std::uint8_t>((value >> 24U) & 0xFFU);
}
// Stop responsiveness (#26). The blocking USB loops keep their per-call
// timeouts short and re-poll this flag so a SIGTERM/SIGINT delivered mid-sweep
// is honoured within a few hundred ms instead of only after the whole sweep
// budget has elapsed.
//
// `main()` installs its own SIGINT/SIGTERM handlers before any driver is
// opened, so we chain to (and preserve) whatever handler is already installed
// rather than clobbering it. All state lives in single inline-static slots so
// every translation unit observes the same flag and chain table.
[[nodiscard]] inline auto native_stop_flag() -> volatile std::sig_atomic_t& {
static volatile std::sig_atomic_t flag = 0;
return flag;
}
[[nodiscard]] inline auto native_chained_handlers() -> std::array<struct sigaction, NSIG>& {
static std::array<struct sigaction, NSIG> handlers{};
return handlers;
}
inline void native_stop_signal_handler(int signal_number) {
native_stop_flag() = 1;
// Chain to the previously installed disposition (e.g. main()'s handler) so
// process-level stop semantics are unchanged.
if (signal_number < 0 || signal_number >= NSIG) {
return;
}
const auto& prior = native_chained_handlers()[static_cast<std::size_t>(signal_number)];
if ((prior.sa_flags & SA_SIGINFO) == 0 && prior.sa_handler != nullptr &&
prior.sa_handler != SIG_DFL && prior.sa_handler != SIG_IGN &&
prior.sa_handler != native_stop_signal_handler) {
prior.sa_handler(signal_number);
}
}
// Lazily install the chaining stop observer for one signal. Must be called
// after main() has installed its handlers (i.e. from open_native()).
inline void install_native_stop_observer_for(int signal_number) {
if (signal_number < 0 || signal_number >= NSIG) {
return;
}
struct sigaction current{};
if (sigaction(signal_number, nullptr, &current) != 0) {
return;
}
if (current.sa_handler == native_stop_signal_handler) {
return; // Already installed; do not chain to ourselves.
}
native_chained_handlers()[static_cast<std::size_t>(signal_number)] = current;
struct sigaction action{};
action.sa_handler = native_stop_signal_handler;
sigemptyset(&action.sa_mask);
action.sa_flags = current.sa_flags & ~SA_SIGINFO; // Keep flags such as SA_RESTART.
sigaction(signal_number, &action, nullptr);
}
inline void install_native_stop_observer() {
install_native_stop_observer_for(SIGINT);
install_native_stop_observer_for(SIGTERM);
}
[[nodiscard]] inline auto native_stop_requested() -> bool {
return native_stop_flag() != 0;
}
} // namespace radar::drivers::librevna::detail
@@ -1,103 +0,0 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <deque>
#include <optional>
#include <span>
#include <string>
#include <vector>
#if __has_include(<libusb-1.0/libusb.h>)
#include <libusb-1.0/libusb.h>
#else
#include <libusb.h>
#endif
#include "radar_driver.hpp"
#include "run_config.hpp"
namespace radar::drivers {
/**
* @brief Runtime settings for minimal LibreVNA acquisition.
*/
struct LibreVnaMinimalDriverSettings {
std::string serial{};
config::RadarSweepSettings sweep{};
};
/**
* @brief Minimal radar driver that acquires forward sweeps from LibreVNA.
*
* This class intentionally keeps scope narrow: open/close transport and
* acquire one sweep. Full device configuration is expected to be done by the
* Python layer before this process starts.
*/
class LibreVnaMinimalDriver final : public RadarDriver {
public:
explicit LibreVnaMinimalDriver(LibreVnaMinimalDriverSettings settings);
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
private:
/**
* @brief Raw protocol packet decoded from framed USB stream.
*/
struct NativePacket {
std::uint8_t packet_type = 0;
std::vector<std::uint8_t> payload{};
};
/**
* @brief One decoded VNADatapoint containing complex values for one point.
*/
struct DecodedVnaDatapoint {
std::uint32_t point_number = 0;
float frequency_hz = 0.0F;
ipc::Complex32 s11{};
ipc::Complex32 s21{};
bool has_s11 = false;
bool has_s21 = false;
};
[[nodiscard]] auto acquire_native() -> SweepTrace;
void open_native();
void close_native();
void send_packet(std::uint8_t packet_type, std::span<const std::uint8_t> payload, bool require_ack);
void send_packet_no_payload(std::uint8_t packet_type, bool require_ack);
void wait_for_ack(std::chrono::steady_clock::time_point deadline);
[[nodiscard]] auto wait_for_packet(std::uint8_t expected_type, std::chrono::steady_clock::time_point deadline)
-> NativePacket;
void pump_usb(std::chrono::steady_clock::time_point deadline);
void decode_frames_from_buffer();
[[nodiscard]] auto pop_packet(std::uint8_t packet_type) -> std::optional<NativePacket>;
[[nodiscard]] static auto encode_frame(std::uint8_t packet_type, std::span<const std::uint8_t> payload)
-> std::vector<std::uint8_t>;
[[nodiscard]] static auto decode_vna_datapoint_traces(
std::span<const std::uint8_t> payload,
DecodedVnaDatapoint& datapoint_out
) -> bool;
[[nodiscard]] static auto crc32(std::span<const std::uint8_t> data) -> std::uint32_t;
void validate_device_info_payload(std::span<const std::uint8_t> payload) const;
LibreVnaMinimalDriverSettings settings_{};
bool is_open_ = false;
libusb_context* usb_context_ = nullptr;
libusb_device_handle* usb_handle_ = nullptr;
bool interface_claimed_ = false;
std::vector<std::uint8_t> rx_buffer_{};
std::deque<NativePacket> packet_queue_{};
std::uint16_t protocol_version_ = 0;
std::uint8_t device_num_ports_ = 0;
};
} // namespace radar::drivers
@@ -1,183 +0,0 @@
#include "hmc349a_minimal_driver.hpp"
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <linux/gpio.h>
#include <stdexcept>
#include <sys/ioctl.h>
#include <unistd.h>
#include <utility>
namespace radar::drivers {
namespace {
// A transient ioctl failure (e.g. EINTR/EAGAIN/EBUSY under load) should not be
// immediately fatal; retry a small number of times before giving up.
constexpr int kSwitchIoctlRetries = 3;
// Compute the GPIO line values for a given logical position, honouring inverted
// control logic. Shared by switch_native() and the teardown default-drive path.
[[nodiscard]] auto make_line_values(const HMC349AMinimalDriverSettings& settings, std::uint32_t position)
-> gpio_v2_line_values {
const std::uint8_t requested_state = static_cast<std::uint8_t>(position & 0x01U);
const std::uint8_t control_state = settings.invert_logic ? (requested_state ^ 0x01U) : requested_state;
gpio_v2_line_values values{};
values.mask = (1ULL << 0U);
values.bits = static_cast<std::uint64_t>(control_state);
return values;
}
void validate_open_settings(const HMC349AMinimalDriverSettings& settings) {
if (settings.positions == 0U || settings.positions > 2U) {
throw std::runtime_error("HMC349A switch positions must be in range [1, 2] for " + settings.name);
}
if (settings.default_position >= settings.positions) {
throw std::runtime_error("Switch default_position out of range for " + settings.name);
}
}
} // namespace
HMC349AMinimalDriver::HMC349AMinimalDriver(HMC349AMinimalDriverSettings settings)
: settings_(std::move(settings)) {}
HMC349AMinimalDriver::~HMC349AMinimalDriver() {
// Safety net: if the owner never called close() (e.g. on crash/unwind),
// still drive the RF path to a known-safe state and release the GPIO lines.
try {
close();
} catch (...) {
}
}
void HMC349AMinimalDriver::open() {
if (is_open_) {
return;
}
validate_open_settings(settings_);
if (settings_.mode == config::DriverMode::Native) {
open_native();
}
is_open_ = true;
switch_to(settings_.default_position);
}
void HMC349AMinimalDriver::close() {
if (!is_open_) {
return;
}
if (settings_.mode == config::DriverMode::Native) {
close_native();
}
is_open_ = false;
}
auto HMC349AMinimalDriver::position_count() const -> std::uint32_t {
return settings_.positions;
}
void HMC349AMinimalDriver::switch_to(std::uint32_t position) {
if (!is_open_) {
throw std::runtime_error("Switch driver is not open for " + settings_.name);
}
if (position >= settings_.positions) {
throw std::runtime_error("Switch position out of range for " + settings_.name);
}
switch (settings_.mode) {
case config::DriverMode::Mock:
break;
case config::DriverMode::Native:
switch_native(position);
break;
default:
throw std::runtime_error("Unsupported switch driver mode for " + settings_.name);
}
current_position_ = position;
}
void HMC349AMinimalDriver::open_native() {
if (settings_.gpio_chip.empty()) {
throw std::runtime_error("gpio_chip is empty for native switch " + settings_.name);
}
if (settings_.pin_a < 0) {
throw std::runtime_error("pin_a (control) is invalid for native switch " + settings_.name);
}
chip_fd_ = ::open(settings_.gpio_chip.c_str(), O_RDONLY | O_CLOEXEC);
if (chip_fd_ < 0) {
throw std::runtime_error(
"Failed to open gpio chip '" + settings_.gpio_chip + "' for " + settings_.name + ": " + std::strerror(errno)
);
}
gpio_v2_line_request request{};
// Bit 0 controls pin_a.
request.offsets[0] = static_cast<std::uint32_t>(settings_.pin_a);
request.num_lines = 1;
request.config.flags = GPIO_V2_LINE_FLAG_OUTPUT;
std::snprintf(request.consumer, sizeof(request.consumer), "radar_%s", settings_.name.c_str());
if (::ioctl(chip_fd_, GPIO_V2_GET_LINE_IOCTL, &request) != 0) {
const auto error = std::strerror(errno);
::close(chip_fd_);
chip_fd_ = -1;
throw std::runtime_error("Failed to request GPIO lines for " + settings_.name + ": " + error);
}
line_fd_ = request.fd;
if (line_fd_ < 0) {
::close(chip_fd_);
chip_fd_ = -1;
throw std::runtime_error("GPIO line request returned invalid fd for " + settings_.name);
}
}
void HMC349AMinimalDriver::close_native() {
// Leave the RF path in its known-safe default position before releasing the
// line, so shutdown/crash never strands the switch in an arbitrary state.
if (line_fd_ >= 0 && settings_.default_position < settings_.positions) {
auto values = make_line_values(settings_, settings_.default_position);
// Best-effort: do not throw out of the teardown path.
::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values);
}
if (line_fd_ >= 0) {
::close(line_fd_);
line_fd_ = -1;
}
if (chip_fd_ >= 0) {
::close(chip_fd_);
chip_fd_ = -1;
}
}
void HMC349AMinimalDriver::switch_native(std::uint32_t position) {
if (line_fd_ < 0) {
throw std::runtime_error("Native GPIO line fd is not open for " + settings_.name);
}
auto values = make_line_values(settings_, position);
// Retry transient ioctl failures so a momentary hiccup is recoverable
// instead of aborting the whole sweep; only the final attempt is fatal.
int last_errno = 0;
for (int attempt = 0; attempt < kSwitchIoctlRetries; ++attempt) {
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) == 0) {
return;
}
last_errno = errno;
}
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(last_errno));
}
} // namespace radar::drivers
@@ -1,53 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include "run_config.hpp"
#include "switch_driver.hpp"
namespace radar::drivers {
/**
* @brief Runtime settings for the HMC349A GPIO switch driver.
*/
struct HMC349AMinimalDriverSettings {
std::string name{};
config::DriverMode mode = config::DriverMode::Native;
std::uint32_t positions = 2;
std::uint32_t default_position = 0;
std::string gpio_chip = "/dev/gpiochip0";
std::int32_t pin_a = 17; ///< Control pin.
bool invert_logic = false; ///< Invert control logic for position mapping.
};
/**
* @brief Minimal HMC349A implementation for deterministic GPIO control.
*
* Native mode uses Linux GPIO character-device API (uAPI v2).
* Mock mode keeps state in memory without touching hardware.
*/
class HMC349AMinimalDriver final : public SwitchDriver {
public:
explicit HMC349AMinimalDriver(HMC349AMinimalDriverSettings settings);
~HMC349AMinimalDriver() override; // Leaves RF path safe even if close() was skipped.
void open() override;
void close() override;
[[nodiscard]] auto position_count() const -> std::uint32_t override;
void switch_to(std::uint32_t position) override;
private:
void open_native();
void close_native();
void switch_native(std::uint32_t position);
HMC349AMinimalDriverSettings settings_{};
bool is_open_ = false;
std::uint32_t current_position_ = 0;
int chip_fd_ = -1;
int line_fd_ = -1;
};
} // namespace radar::drivers
@@ -1,60 +0,0 @@
#pragma once
#include <atomic>
#include <cstdint>
#include "radar_driver.hpp"
#include "run_config.hpp"
#include "shm_ring.hpp"
#include "switch_driver.hpp"
namespace radar::acq {
/**
* @brief Produces raw sweep collections for configured switch combinations.
*
* Lifecycle:
* 1. Open radar and switch drivers.
* 2. Iterate configured combinations, acquire one sweep per combination.
* 3. Publish serialized collections into the raw shared-memory ring.
*/
class SweepOrchestrator {
public:
/**
* @param config Runtime config loaded from JSON.
* @param radar_driver Radar device implementation.
* @param input_switch_driver Switch connected to radar input path.
* @param output_switch_driver Switch connected to radar output path.
* @param raw_ring Shared-memory ring for raw collections.
* @param raw_tap_ring Optional tap ring for GUI/debug readers.
*/
SweepOrchestrator(
const config::RunConfig& config,
drivers::RadarDriver& radar_driver,
drivers::SwitchDriver& input_switch_driver,
drivers::SwitchDriver& output_switch_driver,
ipc::ShmRing& raw_ring,
ipc::ShmRing* raw_tap_ring = nullptr
);
/**
* @brief Run acquisition loop until stop is requested or single run completes.
*/
void run(const std::atomic<bool>& stop_requested);
private:
/**
* @brief Acquire one collection for all configured combinations.
*/
[[nodiscard]] auto acquire_one_collection(std::uint64_t collection_id, const std::atomic<bool>& stop_requested)
-> ipc::RawSweepCollection;
const config::RunConfig& config_;
drivers::RadarDriver& radar_driver_;
drivers::SwitchDriver& input_switch_driver_;
drivers::SwitchDriver& output_switch_driver_;
ipc::ShmRing& raw_ring_;
ipc::ShmRing* raw_tap_ring_ = nullptr;
};
} // namespace radar::acq
@@ -1,150 +0,0 @@
#include <atomic>
#include <csignal>
#include <exception>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include "h7992_minimal_driver.hpp"
#include "hmc349a_minimal_driver.hpp"
#include "librevna_minimal_driver.hpp"
#include "radar_driver.hpp"
#include "run_config.hpp"
#include "shm_ring.hpp"
#include "sweep_orchestrator.hpp"
namespace {
constexpr const char* kDefaultConfigPath = "run_config.json";
constexpr const char* kLibreVnaModel = "librevna";
constexpr const char* kLibreVnaMultiModel = "librevna_multi";
std::atomic<bool> g_stop_requested{false};
void signal_handler(int /*signal*/) {
g_stop_requested.store(true, std::memory_order_relaxed);
}
void install_signal_handlers() {
std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, signal_handler);
}
[[nodiscard]] auto read_config_path(int argc, char** argv) -> std::string {
std::string config_path = kDefaultConfigPath;
for (int index = 1; index < argc; ++index) {
const std::string arg = argv[index];
if (arg == "--config" && (index + 1) < argc) {
config_path = argv[++index];
}
}
return config_path;
}
[[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{
.serial = config.serial,
.sweep = config.sweep,
}
);
}
if (config.model == kLibreVnaMultiModel) {
throw std::runtime_error("librevna_multi is handled by python_app.scripts.matrix_raw_producer");
}
throw std::runtime_error("Unsupported radar.model for sweep_orchestrator: " + config.model);
}
[[nodiscard]] auto make_h7992_driver(const radar::config::SwitchConfig& config)
-> std::unique_ptr<radar::drivers::SwitchDriver> {
return std::make_unique<radar::drivers::H7992MinimalDriver>(
radar::drivers::H7992MinimalDriverSettings{
.name = config.name,
.mode = config.driver_mode,
.positions = config.positions,
.default_position = config.default_position,
.gpio_chip = config.gpio_chip,
.pin_a = config.pin_a,
.pin_b = config.pin_b,
}
);
}
[[nodiscard]] auto make_hmc349a_driver(const radar::config::SwitchConfig& config)
-> std::unique_ptr<radar::drivers::SwitchDriver> {
return std::make_unique<radar::drivers::HMC349AMinimalDriver>(
radar::drivers::HMC349AMinimalDriverSettings{
.name = config.name,
.mode = config.driver_mode,
.positions = config.positions,
.default_position = config.default_position,
.gpio_chip = config.gpio_chip,
.pin_a = config.pin_a,
.invert_logic = config.invert_logic,
}
);
}
[[nodiscard]] auto make_switch_driver(const radar::config::SwitchConfig& config)
-> std::unique_ptr<radar::drivers::SwitchDriver> {
using radar::config::SwitchDriverKind;
switch (config.driver_kind) {
case SwitchDriverKind::H7992:
return make_h7992_driver(config);
case SwitchDriverKind::HMC349A:
return make_hmc349a_driver(config);
default:
throw std::runtime_error("Unsupported switch driver kind");
}
}
} // namespace
int main(int argc, char** argv) {
install_signal_handlers();
try {
const auto config_path = read_config_path(argc, argv);
const auto config = radar::config::load_run_config(config_path);
auto raw_ring = radar::ipc::ShmRing::open_or_create(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes
);
auto raw_tap_ring = radar::ipc::ShmRing::open_or_create(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes
);
auto radar_driver = make_radar_driver(config.radar);
auto input_switch = make_switch_driver(config.input_switch);
auto output_switch = make_switch_driver(config.output_switch);
radar::acq::SweepOrchestrator orchestrator(
config,
*radar_driver,
*input_switch,
*output_switch,
raw_ring,
&raw_tap_ring
);
orchestrator.run(g_stop_requested);
return 0;
} catch (const std::exception& exception) {
std::cerr << "sweep_orchestrator error: " << exception.what() << '\n';
return 1;
}
}
@@ -1,298 +0,0 @@
#include "sweep_orchestrator.hpp"
#include "runtime_wait.hpp"
#include <algorithm>
#include <cstdint>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <vector>
namespace radar::acq {
namespace {
// Wait-for-device retry tuning (mirror of python matrix_raw_producer._open_radar_with_retry):
// a device that is absent at boot or disappears mid-run must never kill the orchestrator,
// only make it wait. Backoff is capped so a long absence does not busy-spin, and every wait
// is interruptible by stop_requested for a prompt clean exit.
constexpr std::uint32_t kOpenRetryMinMs = 1'000U;
constexpr std::uint32_t kOpenRetryMaxMs = 10'000U;
// Throttle open-failure logging during a long wait so a permanently absent device does not
// flood the process log: log the first failure, then every Nth attempt.
constexpr std::uint64_t kOpenRetryLogEvery = 30U;
[[nodiscard]] inline auto should_stop(const std::atomic<bool>& stop_requested) -> bool {
return stop_requested.load(std::memory_order_relaxed);
}
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");
}
}
class DriverLifecycleGuard {
public:
DriverLifecycleGuard(
drivers::RadarDriver& radar_driver,
drivers::SwitchDriver& input_switch_driver,
drivers::SwitchDriver& output_switch_driver
)
: radar_driver_(radar_driver),
input_switch_driver_(input_switch_driver),
output_switch_driver_(output_switch_driver) {}
~DriverLifecycleGuard() noexcept {
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();
}
// Open all devices, retrying forever with capped exponential backoff until success or
// stop. Used for both the initial open and every in-loop reopen, so a device that is
// absent at boot or disappears mid-run never kills the orchestrator — it just waits.
// Returns false only if a stop was requested before any device became available.
[[nodiscard]] auto open_all_with_retry(const std::atomic<bool>& stop_requested) -> bool {
// Drop any partial state from a previous open before retrying.
close_all();
std::uint64_t attempt = 0;
std::uint32_t delay_ms = kOpenRetryMinMs;
while (!should_stop(stop_requested)) {
try {
open_all();
if (attempt > 0) {
std::cerr << "sweep_orchestrator: devices opened after " << (attempt + 1) << " attempt(s)\n";
}
return true;
} catch (const std::exception& exc) {
// Best-effort: drop any partial open before the next attempt.
close_all();
++attempt;
if (attempt == 1 || attempt % kOpenRetryLogEvery == 0) {
std::cerr << "sweep_orchestrator: devices not available (attempt " << attempt
<< "); retrying up to every " << (kOpenRetryMaxMs / 1'000U)
<< "s until present: " << exc.what() << '\n';
}
if (config::wait_or_stop(delay_ms, stop_requested)) {
return false;
}
delay_ms = std::min(delay_ms * 2U, kOpenRetryMaxMs);
}
}
return false;
}
void close_all() noexcept {
if (!active_) {
return;
}
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:
drivers::RadarDriver& radar_driver_;
drivers::SwitchDriver& input_switch_driver_;
drivers::SwitchDriver& output_switch_driver_;
bool active_ = false;
};
// Worst-case serialized size of a collection given the configured combo count and sweep
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
// + capture_start_ns(8) + capture_end_ns(8)
// + trace capture window count(4) = 44 bytes
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
// + trailer: capture_start_ns(8) + capture_end_ns(8) = 16 bytes
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
constexpr std::size_t kCollectionHeaderBytes = 44U;
constexpr std::size_t kTraceHeaderBytes = 12U;
constexpr std::size_t kBytesPerPoint = 20U;
constexpr std::size_t kTraceTrailerBytes = 16U;
const std::size_t per_trace =
kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint) + kTraceTrailerBytes;
return kCollectionHeaderBytes + (combo_count * per_trace);
}
// Publish a collection on the primary raw ring; tap pushes are strictly best-effort.
// Never throws: an oversize payload is logged-and-dropped (the slot-size budget is also
// validated up front at startup, so this guards only against unexpected runtime growth).
// Returns true if the primary push succeeded.
[[nodiscard]] auto publish_collection(
ipc::ShmRing& raw_ring,
ipc::ShmRing* raw_tap_ring,
const ipc::RawSweepCollection& collection,
std::uint64_t& oversize_drop_count
) -> bool {
const auto serialized_collection = ipc::serialize_raw_collection(collection);
if (!raw_ring.push(serialized_collection)) {
// Log-and-drop an oversize payload rather than aborting the long-running loop.
// Throttle so a persistently oversize payload cannot flood the log.
if (oversize_drop_count % 100 == 0) {
std::cerr << "sweep_orchestrator: dropped oversize raw payload (" << serialized_collection.size()
<< " bytes > slot " << raw_ring.slot_size_bytes() << "; drop count="
<< (oversize_drop_count + 1) << ")\n";
}
++oversize_drop_count;
return false;
}
// Tap ring is for GUI/debug observers only: a tap failure must never abort the primary path.
if (raw_tap_ring != nullptr) {
(void)raw_tap_ring->push(serialized_collection);
}
return true;
}
} // namespace
SweepOrchestrator::SweepOrchestrator(
const config::RunConfig& config,
drivers::RadarDriver& radar_driver,
drivers::SwitchDriver& input_switch_driver,
drivers::SwitchDriver& output_switch_driver,
ipc::ShmRing& raw_ring,
ipc::ShmRing* raw_tap_ring
)
: config_(config),
radar_driver_(radar_driver),
input_switch_driver_(input_switch_driver),
output_switch_driver_(output_switch_driver),
raw_ring_(raw_ring),
raw_tap_ring_(raw_tap_ring) {}
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
// Fail fast on a config error: a slot that is too small for the worst-case payload can
// never carry a full collection, so report it clearly at startup instead of dropping
// every collection at runtime.
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
throw std::runtime_error(
"Raw ring slot_size_bytes (" + std::to_string(raw_ring_.slot_size_bytes())
+ ") is too small for the worst-case serialized collection (" + std::to_string(worst_case_bytes)
+ " bytes for " + std::to_string(config_.run_combos.size()) + " combos x "
+ std::to_string(config_.radar.sweep.points) + " points)"
);
}
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
// Wait for the devices to become available before starting: an absent device
// makes the orchestrator wait, not exit.
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
return; // stop requested before any device became available
}
std::uint64_t collection_id = 0;
std::uint64_t oversize_drop_count = 0;
while (!should_stop(stop_requested)) {
try {
auto raw_collection = acquire_one_collection(++collection_id, stop_requested);
if (raw_collection.traces.empty()) {
if (!config_.runtime.continuous) {
break;
}
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
continue;
}
(void)publish_collection(raw_ring_, raw_tap_ring_, raw_collection, oversize_drop_count);
if (!config_.runtime.continuous) {
break;
}
} catch (const std::exception& exc) {
// Treat any in-loop device error (timeout/NACK/transient USB or socket glitch/
// device-not-found-after-glitch) as recoverable: close, wait for the device to
// come back, and continue. This mirrors the python producer's reconnect-forever
// policy so a long-running headless appliance survives transient hardware hiccups
// instead of exiting. Genuinely fatal config errors are caught at startup above
// (and in main()), reserving non-zero exit for those.
std::cerr << "sweep_orchestrator: acquisition failed; reopening and waiting for the device: "
<< exc.what() << '\n';
if (!config_.runtime.continuous) {
throw; // single-run mode has no recovery path; surface the failure
}
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
break; // stop requested while waiting to reopen
}
}
}
lifecycle_guard.close_all();
}
auto SweepOrchestrator::acquire_one_collection(
std::uint64_t collection_id,
const std::atomic<bool>& stop_requested
) -> ipc::RawSweepCollection {
ipc::RawSweepCollection collection{};
collection.collection_id = collection_id;
collection.monotonic_ns = ipc::current_monotonic_ns();
collection.traces.reserve(config_.run_combos.size());
collection.capture_start_ns = ipc::current_monotonic_ns();
bool interrupted = false;
for (const auto& combo : config_.run_combos) {
if (should_stop(stop_requested)) {
interrupted = true;
break;
}
// Hardware flow for each combo:
// 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);
if (config::wait_or_stop(config_.runtime.settling_ms, stop_requested)) {
interrupted = true;
break;
}
// Stamp around the sweep only: the switch drive and settling above belong to
// neither the previous combo nor this one, so excluding them keeps the window
// an honest "when was this combo actually measured".
const auto sweep_start_ns = ipc::current_monotonic_ns();
auto sweep = radar_driver_.acquire_sweep();
const auto sweep_end_ns = ipc::current_monotonic_ns();
validate_sweep(sweep);
ipc::SweepTraceBlock trace{};
trace.combo = combo;
trace.frequency_hz = std::move(sweep.frequency_hz);
trace.s11 = std::move(sweep.s11);
trace.s21 = std::move(sweep.s21);
trace.capture_start_ns = sweep_start_ns;
trace.capture_end_ns = sweep_end_ns;
collection.traces.push_back(std::move(trace));
}
if (interrupted) {
// Do not emit partial collections when stop was requested mid-cycle.
collection.traces.clear();
collection.capture_start_ns = 0;
collection.capture_end_ns = 0;
return collection;
}
collection.capture_end_ns = collection.traces.empty() ? 0U : ipc::current_monotonic_ns();
return collection;
}
} // namespace radar::acq
@@ -1,18 +0,0 @@
"""Extract production CUDA kernels and host helpers for the test-only adapter."""
from pathlib import Path
import re
import sys
source = (Path(__file__).resolve().parents[1] / "processing/processors/src/sar3d_gpu.cpp").read_text()
kernel = re.search(r'kKernelSource\s*=\s*R"CUDA\((.*?)\)CUDA";', source, re.DOTALL)
if kernel is None:
raise RuntimeError("SAR CUDA kernel literal was not found")
first = source.index("constexpr std::size_t kMaxIndexedItems")
last = source.index("} // namespace", first)
destination = Path(sys.argv[1])
destination.mkdir(parents=True, exist_ok=True)
(destination / "sar3d_kernels.inc").write_text(kernel.group(1))
helpers = re.sub(r"^#(?:ifdef SAR3D_WITH_CUDA|endif)$", "", source[first:last], flags=re.MULTILINE)
(destination / "sar3d_helpers.inc").write_text(helpers)
@@ -0,0 +1,204 @@
"""Generate small SAR3D references by executing notebook cells 0 and 4.
Requires the original, untracked Multi_antenna_3D_clean_v1.ipynb; a missing notebook
is an error. NumPy/SciPy execute its function/class ASTs unchanged. Only parameter
values are replaced; plotting imports and the interactive top-level runs are
excluded. No production SAR implementation or hand-transcribed oracle is used.
Run from the repository root:
.venv/bin/python data_acq_and_processing/tests/generate_sar3d_reference.py
"""
from __future__ import annotations
import argparse
import ast
import hashlib
import json
from pathlib import Path
import tempfile
import numpy as np
import scipy
ROOT = Path(__file__).resolve().parents[2]
REFERENCE = Path(__file__).with_name("sar3d_reference.npz")
def notebook_functions(notebook: Path, parameters: dict) -> tuple[dict, dict]:
cells = json.loads(notebook.read_text(encoding="utf-8"))["cells"]
namespace = {"__name__": __name__}
hashes = {}
for index in (0, 4):
source = "".join(cells[index]["source"])
hashes[str(index)] = hashlib.sha256(source.encode()).hexdigest()
tree = ast.parse(source, filename=f"{notebook.name}:cell{index}")
definitions = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.ImportFrom)):
definitions.append(node)
elif isinstance(node, ast.Import):
if not any(alias.name.startswith(("matplotlib", "plotly")) for alias in node.names):
definitions.append(node)
elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
try:
namespace[node.targets[0].id] = ast.literal_eval(node.value)
except (ValueError, TypeError):
pass # Dataset loading and the interactive run are supplied below.
namespace.update(parameters)
exec(compile(ast.Module(definitions, type_ignores=[]), f"{notebook.name}:cell{index}",
"exec", dont_inherit=True), namespace)
return namespace, hashes
def scene() -> dict:
"""Deterministic two-echo input with motion, vertical baselines and four pairs."""
request = dict(
tx=[[0, -.4, 0, 0], [1, .45, .2, .05]],
rx=[[0, -.2, .1, 0], [1, .3, .35, .02]], f_start=1e9, f_stop=4e9,
min_depth=1., max_depth=3., speed=.4, pitch=13., ground=.3,
voxel=.4, radius=1, power=1.5, compensation=True, normalize=True,
)
# Match the float32 settings/geometry accepted by the native fixture.
for key in ("tx", "rx"):
request[key] = np.asarray(request[key], dtype=np.float32).astype(float).tolist()
frequency = np.linspace(1e9, 4e9, 201, dtype=np.float32)
members = []
for frame, offset in enumerate((0, 180_000_000, 510_000_000)):
traces = []
start = 10_000_000_000 + offset
for tx in range(2):
for rx in range(2):
samples = ((1 + .1 * rx) * np.exp(-2j * np.pi * frequency.astype(float)
* (4.8 + .08 * tx - .03 * frame) / 3e8)
+ .35 * np.exp(-2j * np.pi * frequency.astype(float) * (5.6 - .05 * rx) / 3e8)).astype(np.complex64)
traces.append(dict(input=rx, output=tx, start=start, end=start + 165_000_000,
frequency=frequency.tolist(), s21=np.column_stack((samples.real, samples.imag)).tolist()))
members.append(dict(id=frame + 1, timestamp=start + 165_000_000, traces=traces))
request.update(previous=members[:-1], latest=members[-1])
return request
def write_dataset(directory: Path, request: dict) -> list[Path]:
"""Supply the notebook's original config_profile/meta/NPY input contract."""
profile = {"gpr": {}, "run": {"combos": []}}
for kind, position in (("tx", "output_pos"), ("rx", "input_pos")):
profile["gpr"][f"{kind}_geometry"] = [
{position: int(index), "x_m": x, "y_m": y, "z_m": z}
for index, x, y, z in request[kind]
]
profile["run"]["combos"] = [
{"input": trace["input"], "output": trace["output"]} for trace in request["latest"]["traces"]
]
(directory / "config_profile.json").write_text(json.dumps(profile), encoding="utf-8")
snapshots = []
for index, member in enumerate([*request["previous"], request["latest"]]):
snapshot = directory / f'{index:04d}_id{member["id"]}_ns{member["timestamp"]}'
snapshot.mkdir()
metadata = {"traces": []}
for trace in member["traces"]:
stem = f'i{trace["input"]}_o{trace["output"]}'
values = np.asarray(trace["s21"], dtype=np.float32)
np.save(snapshot / f"{stem}_s21.npy", values[:, 0] + 1j * values[:, 1])
np.save(snapshot / f"{stem}_freq.npy", np.asarray(trace["frequency"], dtype=np.float32))
metadata["traces"].append(dict(input=trace["input"], output=trace["output"],
capture_start_ns=trace["start"], capture_end_ns=trace["end"]))
(snapshot / "meta.json").write_text(json.dumps(metadata), encoding="utf-8")
snapshots.append(snapshot)
return snapshots
def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dict]:
snapshots = write_dataset(directory, request)
center = snapshots[max(len(snapshots) - 1 - request["radius"], 0)]
parameters = dict(
INPUT_IDX=[int(row[0]) for row in request["rx"]],
OUTPUT_IDX=[int(row[0]) for row in request["tx"]],
F_START=request["f_start"], F_STOP=request["f_stop"],
MIN_DEPTH=request["min_depth"], MAX_DEPTH=request["max_depth"],
PAIR_NORM_DEPTH_MIN=request["min_depth"], PAIR_NORM_DEPTH_MAX=request["max_depth"],
SPEED_M_S=request["speed"], LOOK_ANGLE_DEG=request["pitch"],
BP_GROUND_DEPTH_M=request["ground"], COMP_RANGE_POWER=request["power"],
SAR_VOXEL_M=(request["voxel"],) * 3, SAR_FRAME_RADIUS=request["radius"],
SAR_USE_COMPENSATION=request["compensation"], SAR_NORMALIZE_EACH_FRAME=request["normalize"],
SAR_SCAN_ROOT=directory, DATA_PATH=center, BG_PATH=directory, v=3e8,
)
ns, hashes = notebook_functions(notebook, parameters)
tx, rx, order, tx_order, pair_count = ns["load_antenna_config_profile"](
directory / "config_profile.json", ns["INPUT_IDX"], ns["OUTPUT_IDX"])
ns["N_tx"], ns["N_rx"] = len(tx), len(rx)
for kind, positions in (("tx", tx), ("rx", rx)):
for axis, values in zip("xyz", ns["positions_to_xyz"](positions).T):
ns[f"{axis}_{kind}"] = values
antenna_x = np.concatenate((ns["x_tx"], ns["x_rx"]))
ns["x_grid_bp"] = np.linspace(antenna_x.min() - 2, antenna_x.max() + 2, ns["NX_BP"])
sweep_time, _, _ = ns["estimate_sweep_time_from_meta"](directory, n_pairs=pair_count)
config = ns["MotionConfig"](
speed_m_s=ns["SPEED_M_S"], look_angle_deg=ns["LOOK_ANGLE_DEG"],
tx_sweep_time_s=sweep_time, frame_acq_time_s=sweep_time * pair_count,
pair_order_phys=order, tx_order_full=tuple(tx_order),
n_freq_hint=len(request["latest"]["traces"][0]["frequency"]),
apply_pair_motion_correction=ns["APPLY_PAIR_MOTION_CORRECTION"],
apply_freq_phase_correction=ns["APPLY_FREQ_PHASE_CORRECTION"],
pair_motion_correction_mode=ns["PAIR_MOTION_CORRECTION_MODE"],
motion_phase_sign=ns["MOTION_PHASE_SIGN"],
)
timestamps, _ = ns["compute_pair_timestamps_for_snapshot"](center, config)
result = ns["sar_compute_volume"](center, None, config, timestamps, verbose=False)
volume, grid = result["volume"], result["grid"]
iy, iz, ix = ns["sar_global_max_index"](volume, grid)
expected = {axis: grid[axis] for axis in "xyz"}
expected.update(front=volume.max(axis=1), side=volume.max(axis=2), top=volume.max(axis=0),
peak=np.array([result["level"], grid["x"][ix], grid["y"][iy], grid["z"][iz]]))
return expected, hashes
def generate(notebook: Path, output: Path) -> None:
# Read up front so a missing notebook cannot produce an incomplete artifact.
notebook.read_text(encoding="utf-8")
cases = [
dict(compensation=True, normalize=True),
dict(compensation=True, normalize=False, speed=-.2, pitch=-7., ground=.5),
dict(compensation=False, normalize=True, f_start=1.3e9, f_stop=3.7e9),
dict(compensation=False, normalize=False, speed=0., pitch=0., radius=0),
]
arrays, configurations = {}, []
for index, settings in enumerate(cases):
request = scene()
request.update(settings)
for key in ("min_depth", "max_depth", "speed", "pitch", "ground", "voxel", "power"):
request[key] = float(np.float32(request[key]))
if index == 1:
# A separate, unequal capture window per output instead of interleaving.
for member in [*request["previous"], request["latest"]]:
for trace in member["traces"]:
if trace["output"] == 0:
trace["end"] = trace["start"] + 80_000_000
else:
trace["start"] += 95_000_000
with tempfile.TemporaryDirectory(prefix="sar3d_notebook_") as directory:
expected, hashes = reference(notebook, request, Path(directory))
if index == 0:
arrays["request"] = np.array(json.dumps(request, separators=(",", ":")))
configurations.append(dict(
settings={key: request[key] for key in settings},
windows=[[[trace["start"], trace["end"]] for trace in member["traces"]]
for member in [*request["previous"], request["latest"]]],
))
arrays.update({f"case_{index}_{key}": value for key, value in expected.items()})
arrays["cases"] = np.array(json.dumps(configurations, separators=(",", ":")))
arrays["provenance"] = np.array(json.dumps(dict(
notebook=notebook.name, cell_source_sha256=hashes, cells=[0, 4], cases=len(cases),
numpy=np.__version__, scipy=scipy.__version__,
method="Unchanged notebook AST functions; max projections of sar_compute_volume output",
), sort_keys=True))
output.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(output, **arrays)
print(f"Wrote {output}: {output.stat().st_size} bytes, {len(cases)} notebook references")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--notebook", type=Path, default=ROOT / "Multi_antenna_3D_clean_v1.ipynb")
parser.add_argument("--output", type=Path, default=REFERENCE)
args = parser.parse_args()
generate(args.notebook, args.output)
@@ -2,10 +2,10 @@
#include "channel_bundle_support.hpp"
#include "data_preprocessor.hpp"
#include "locator/tcp_server.hpp"
#include "object_approach_filter.hpp"
#include "processing_live_config.hpp"
#include "runtime_wait.hpp"
#include "shm_ring.hpp"
#include "sweep_orchestrator.hpp"
#include "tty_protocol_writer.h"
#include <sys/mman.h>
@@ -13,6 +13,7 @@
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
@@ -65,9 +66,9 @@ struct RingName {
std::string name;
explicit RingName(const std::string& suffix = {})
: name("/radar_native_test_" + std::to_string(::getpid()) + suffix) {
ipc::ShmRing::unlink_ring(name);
::shm_unlink(name.c_str());
}
~RingName() { ipc::ShmRing::unlink_ring(name); }
~RingName() { ::shm_unlink(name.c_str()); }
};
auto save_bundle(const Workspace& workspace, const char* name, const ipc::RawSweepCollection& collection)
@@ -84,13 +85,15 @@ void write_u32(std::vector<std::uint8_t>& bytes, std::size_t offset, std::uint32
std::memcpy(bytes.data() + offset, &value, sizeof(value));
}
void test_wire_decoders() {
void test_wire_format() {
ipc::RawSweepCollection raw{.collection_id = 7, .monotonic_ns = 42,
.capture_start_ns = 21, .capture_end_ns = 40,
.traces = {{.combo = {1, 2}, .frequency_hz = {1, 2},
.s11 = {{1, 2}, {3, 4}}, .s21 = {{5, 6}, {7, 8}},
.s21 = {{5, 6}, {7, 8}},
.capture_start_ns = 25, .capture_end_ns = 39}}};
ipc::ResultCollection results{.collection_id = 7, .processing_duration_ns = 17,
ipc::ResultCollection results{.collection_id = 7, .monotonic_ns = 0x0102030405060708ULL,
.processing_duration_ns = 17, .settings_revision = 0xfedcba9876543210ULL,
.capture_start_ns = 21, .capture_end_ns = 40, .speed_m_s = -1.5F,
.collection_payloads = {
{.kind = ipc::ResultKind::TraceComplex, .frequency_hz = {1}, .trace = {{2, 3}}},
{.kind = ipc::ResultKind::ScalarF32, .scalar_value = 4},
@@ -103,32 +106,39 @@ void test_wire_decoders() {
const auto raw_bytes = ipc::serialize_raw_collection(raw);
const auto pre_bytes = ipc::serialize_preprocessed_collection(raw);
const auto result_bytes = ipc::serialize_result_collection(results);
const std::array<std::uint8_t, 64> result_header{
'R', 'S', 'L', '3', 7, 0, 0, 0, 0, 0, 0, 0,
8, 7, 6, 5, 4, 3, 2, 1, 17, 0, 0, 0, 0, 0, 0, 0,
0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe,
21, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0xc0, 0xbf, 6, 0, 0, 0, 1, 0, 0, 0,
};
require(std::equal(result_header.begin(), result_header.end(), result_bytes.begin()),
"RSL3 must preserve exact acquisition identity and settings in its 64-byte header");
require(std::memcmp(raw_bytes.data(), "RAW3", 4) == 0 &&
std::memcmp(pre_bytes.data(), "PRP3", 4) == 0,
"trace collections must use the S21-only version 3 format");
// Collection header + one trace header + two 12-byte points + capture trailer.
require(raw_bytes.size() == 24 + 12 + 2 * 12 + 16 + 4 + 16 && pre_bytes.size() == raw_bytes.size(),
"trace wire format must contain only frequency and S21 samples");
auto old_raw = raw_bytes;
auto old_pre = pre_bytes;
write_u32(old_raw, 0, 0x32574152U);
write_u32(old_pre, 0, 0x32525050U);
expect_error([&] { (void)ipc::deserialize_raw_collection(old_raw); });
expect_error([&] { (void)ipc::deserialize_preprocessed_collection(old_pre); });
require(ipc::serialize_raw_collection(ipc::deserialize_raw_collection(raw_bytes)) == raw_bytes,
"raw roundtrip changed data");
require(ipc::serialize_preprocessed_collection(ipc::deserialize_preprocessed_collection(pre_bytes)) == pre_bytes,
"preprocessed roundtrip changed data");
require(ipc::serialize_result_collection(ipc::deserialize_result_collection(result_bytes)) == result_bytes,
"result roundtrip changed data or empty shape");
for (std::size_t size = 0; size < result_bytes.size(); ++size) {
expect_error([&] { (void)ipc::deserialize_result_collection(std::span(result_bytes).first(size)); });
}
for (std::size_t count_offset : {28U, 32U, 39U}) {
auto corrupt = result_bytes;
write_u32(corrupt, count_offset, std::numeric_limits<std::uint32_t>::max());
expect_error([&] { (void)ipc::deserialize_result_collection(corrupt); });
}
for (auto kind : {ipc::ResultKind::ImageF32, ipc::ResultKind::TableF32}) {
ipc::ResultCollection empty{.collection_payloads = {{.kind = kind}}};
auto corrupt = ipc::serialize_result_collection(empty);
write_u32(corrupt, 39, std::numeric_limits<std::uint32_t>::max());
write_u32(corrupt, 43, std::numeric_limits<std::uint32_t>::max());
expect_error([&] { (void)ipc::deserialize_result_collection(corrupt); });
for (const std::size_t tail : {36U, 20U}) {
expect_error([&] { (void)ipc::deserialize_raw_collection(std::span(raw_bytes).first(raw_bytes.size() - tail)); });
expect_error([&] { (void)ipc::deserialize_preprocessed_collection(std::span(pre_bytes).first(pre_bytes.size() - tail)); });
}
// Deterministic random mutations include wire-compatible valid frames as seeds.
std::mt19937 random(0x52414452U);
for (unsigned iteration = 0; iteration < 10'000U; ++iteration) {
auto bytes = iteration % 3U == 0U ? raw_bytes : iteration % 3U == 1U ? pre_bytes : result_bytes;
auto bytes = iteration % 2U == 0U ? raw_bytes : pre_bytes;
for (unsigned edit = 0; edit < 1U + random() % 5U; ++edit) {
bytes[random() % bytes.size()] = static_cast<std::uint8_t>(random());
}
@@ -136,9 +146,8 @@ void test_wire_decoders() {
bytes.resize(random() % bytes.size());
}
try {
if (iteration % 3U == 0U) { (void)ipc::deserialize_raw_collection(bytes); }
else if (iteration % 3U == 1U) { (void)ipc::deserialize_preprocessed_collection(bytes); }
else { (void)ipc::deserialize_result_collection(bytes); }
if (iteration % 2U == 0U) { (void)ipc::deserialize_raw_collection(bytes); }
else { (void)ipc::deserialize_preprocessed_collection(bytes); }
} catch (const std::bad_alloc&) { throw; }
catch (const std::runtime_error&) {}
}
@@ -151,12 +160,35 @@ void test_configuration(const std::string& seed_path, const Workspace& workspace
const auto save = [&](const Json& config) { std::ofstream(config_path) << config; };
save(seed);
const auto config = config::load_run_config(config_path.string());
auto no_taps = seed;
no_taps["rings"]["preprocessed_tap"]["name"] = "";
save(no_taps);
const auto disabled = config::load_run_config(config_path.string()).rings;
require(disabled.preprocessed_tap.name.empty(),
"explicit empty tap names must disable the optional rings");
for (const auto* key : {"raw", "preprocessed", "results"}) {
auto invalid = no_taps;
invalid["rings"][key]["name"] = "";
save(invalid);
expect_error([&] { (void)config::load_run_config(config_path.string()); });
}
for (const auto* port : {"port1", "port2"}) {
auto with_driver = seed;
with_driver["switches"][port]["driver"] = "h7992";
save(with_driver);
(void)config::load_run_config(config_path.string());
for (const auto& driver : {Json("hmc349a"), Json("unknown"), Json(7)}) {
with_driver["switches"][port]["driver"] = driver;
save(with_driver);
expect_error([&] { (void)config::load_run_config(config_path.string()); });
}
}
auto with_object_calibration = seed;
with_object_calibration["preprocess"]["s21"]["object_calibration"] =
Json{{"set_name", "reflector"}, {"bundle_path", "corrections.bin"}};
save(with_object_calibration);
const auto selected = config::load_run_config(config_path.string()).preprocess.s21.object_calibration;
require(selected.set_name == "reflector" && selected.bundle_path == "corrections.bin",
require(selected.bundle_path == "corrections.bin",
"object calibration asset did not load");
with_object_calibration["preprocess"]["s21"].erase("object_calibration");
save(with_object_calibration);
@@ -167,10 +199,8 @@ void test_configuration(const std::string& seed_path, const Workspace& workspace
save(with_object_calibration);
expect_error([&] { (void)config::load_run_config(config_path.string()); });
}
for (const auto& [pointer, value] : std::array<std::pair<const char*, double>, 8>{{
{"/radar/sweep/start_hz", -1}, {"/radar/sweep/stop_hz", 1e100},
{"/radar/sweep/if_bandwidth_hz", 0}, {"/radar/sweep/points", 0},
{"/radar/sweep/points", 4294967296.0}, {"/run/combos/0/input", 0.0000001},
for (const auto& [pointer, value] : std::array<std::pair<const char*, double>, 3>{{
{"/run/combos/0/input", 0.0000001},
{"/rings/raw/slot_size_bytes", 65}, {"/gpr/rx_geometry/0/x_m", 1e100}}}) {
auto invalid = seed;
invalid[Json::json_pointer(pointer)] = value;
@@ -190,11 +220,6 @@ void test_configuration(const std::string& seed_path, const Workspace& workspace
require(current.bscan_gain == 2 && loader.revision() == revision,
"invalid live config replaced the last valid settings");
}
const std::array<float, 1> nan_axis{std::numeric_limits<float>::quiet_NaN()};
require(!preprocessing::frequency_axes_match(nan_axis, nan_axis), "NaN frequency axes matched");
preprocessing::ChannelTrace invalid_trace{.frequency_hz = {1},
.samples = {{std::numeric_limits<float>::infinity(), 0}}};
expect_error([&] { preprocessing::validate_channel_trace_layout(invalid_trace, "test", {}); });
}
void test_ring() {
@@ -245,38 +270,16 @@ void test_ring() {
}
}
void test_partial_driver_open() {
struct Radar final : drivers::RadarDriver {
unsigned closes = 0;
void open() override {}
void close() override { ++closes; }
auto acquire_sweep() -> drivers::SweepTrace override { return {}; }
} radar;
struct Switch final : drivers::SwitchDriver {
std::atomic<bool>& stop;
bool fail_open = false;
bool fail_close = false;
unsigned closes = 0;
explicit Switch(std::atomic<bool>& stop_flag) : stop(stop_flag) {}
void open() override {
if (fail_open) { stop.store(true); throw std::runtime_error("open failure"); }
}
void close() override { ++closes; if (fail_close) { throw std::runtime_error("close failure"); } }
auto position_count() const -> std::uint32_t override { return 1; }
void switch_to(std::uint32_t) override {}
};
std::atomic<bool> stop{false};
Switch input(stop), output(stop);
input.fail_open = true;
output.fail_close = true;
config::RunConfig config;
config.radar.sweep.points = 1;
config.run_combos = {{0, 0}};
RingName name;
auto ring = ipc::ShmRing::open_or_create(name.name, 2, 256);
acq::SweepOrchestrator(config, radar, input, output, ring).run(stop);
require(radar.closes == 1 && input.closes == 1 && output.closes == 1,
"partial driver startup did not close each driver exactly once");
void test_acquisition_identity() {
processing::ObjectApproachFilter filter;
const std::vector<processing::ObjectApproachFilter::Point> points{{0, 2}};
require(!filter.confirm(points, 1, 1'000'000'000ULL, 0, 0, 2).front(), "first frame confirmed a track");
require(filter.confirm(points, 1, 2'000'000'000ULL, 0, 0, 2).front(),
"reused collection id replaced a distinct acquisition");
require(filter.confirm(points, 1, 2'000'000'000ULL, 0, 0, 2).front(),
"reprocessing duplicated a frame or lost its predecessor");
require(!filter.confirm(points, 7, 1'000'000'000ULL, 0, 0, 2).front(),
"an earlier capture time failed to reset replay history");
}
void test_interruptible_wait() {
@@ -291,6 +294,19 @@ void test_interruptible_wait() {
"large wait delayed shutdown");
}
void test_cosine_notch_taper() {
// Band 2.0-2.1 GHz with 0.1 GHz shoulders: 1 at the outer edges, 0.5 halfway,
// 0 from the band edges inwards.
const std::vector<float> frequency_hz{1.85e9F, 1.9e9F, 1.95e9F, 2.0e9F, 2.05e9F,
2.1e9F, 2.15e9F, 2.2e9F, 2.25e9F};
const std::vector<float> expected{1.0F, 1.0F, 0.5F, 0.0F, 0.0F, 0.0F, 0.5F, 1.0F, 1.0F};
const auto mask = preprocessing::build_notch_mask(frequency_hz,
{.enabled = true, .bands_hz = {{2.0e9F, 2.1e9F}}, .taper_width_hz = 1e8F, .taper_type = "cosine"});
for (std::size_t index = 0U; index < expected.size(); ++index) {
require(std::abs(mask[index] - expected[index]) < 1e-3F, "cosine notch shoulders are not 1 -> 0 towards the band");
}
}
void test_csv_failures(const Workspace& workspace) {
const auto csv = workspace.path / "capture.csv";
const auto spool = workspace.path / "capture.capture_spool.bin";
@@ -328,37 +344,32 @@ void test_csv_failures(const Workspace& workspace) {
void test_asset_reload_preserves_data(const Workspace& workspace) {
const auto save = [&](const char* name, float sample) {
return save_bundle(workspace, name, {.traces = {
{.frequency_hz = {1}, .s11 = {{sample, 0}}, .s21 = {{sample, 0}}}}});
{.frequency_hz = {1}, .s21 = {{sample, 0}}}}});
};
const auto open = save("open.bin", 1);
const auto short_standard = save("short.bin", -1);
const auto load = save("load.bin", 0);
const auto nan = save("nan.bin", std::numeric_limits<float>::quiet_NaN());
preprocessing::S21ReferenceBundle s21_reference;
s21_reference.load(open);
expect_error([&] { s21_reference.load(nan); });
require(s21_reference.resolve({}).samples.front().re == 1, "failed S21 reload lost previous reference");
preprocessing::S21CalibrationBundle s21_calibration;
s21_calibration.load(open);
s21_calibration.load(save("through.bin", 2));
expect_error([&] { s21_calibration.load(nan); });
require(s21_calibration.is_enabled(), "failed S21 reload disabled previous calibration");
preprocessing::S11ReferenceBundle s11_reference;
s11_reference.load(open);
expect_error([&] { s11_reference.load(nan); });
require(s11_reference.resolve({}).samples.front().re == 1, "failed S11 reload lost previous reference");
preprocessing::S11CalibrationBundle s11_calibration;
s11_calibration.load(open, short_standard, load);
expect_error([&] { s11_calibration.load(open, open, load); });
const std::array<float, 1> axis{1};
require(s11_calibration.apply({}, axis, {{0.5, 0}}).front().re == 0.5F,
"failed OSL reload lost previous calibration");
const std::array<float, 1> frequency{1};
std::array<ipc::Complex32, 1> samples{{{6, 8}}};
s21_calibration.apply({}, frequency, samples);
require(samples[0].re == 3 && samples[0].im == 4, "failed S21 reload changed previous calibration");
const std::array<float, 1> nan_axis{std::numeric_limits<float>::quiet_NaN()};
expect_error([&] { s21_calibration.apply({}, nan_axis, samples); });
preprocessing::S21ReferenceBundle reference;
reference.load(open, s21_calibration, {{}});
expect_error([&] { reference.load(nan, s21_calibration, {{}}); });
ipc::SweepTraceBlock trace{.frequency_hz = {1}, .s21 = {{2, 0}}};
reference.apply(trace);
require(trace.s21[0].re == 1.5F, "failed reference reload changed the calibrated reference");
}
void test_object_calibration(const Workspace& workspace) {
const ipc::SweepTraceBlock first{.combo = {7, 3}, .frequency_hz = {1e9F, 2e9F},
.s11 = {{1, 2}, {3, 4}}, .s21 = {{2, 3}, {4, 5}}, .capture_start_ns = 5, .capture_end_ns = 15};
.s21 = {{2, 3}, {4, 5}}, .capture_start_ns = 5, .capture_end_ns = 15};
const ipc::SweepTraceBlock second{.combo = {1, 9}, .frequency_hz = {3e9F, 4e9F, 5e9F},
.s11 = {{}, {}, {}}, .s21 = {{6, 7}, {8, 9}, {10, 11}}};
.s21 = {{6, 7}, {8, 9}, {10, 11}}};
auto factors_first = first;
factors_first.s21 = {{0, 2}, {2, 0}};
auto factors_second = second;
@@ -390,7 +401,7 @@ void test_object_calibration(const Workspace& workspace) {
trace = first;
correction.apply(trace);
require(ipc::serialize_raw_collection({.traces = {trace}}) == ipc::serialize_raw_collection({.traces = {expected}}),
"object calibration changed S11, timing, pair identity, or multiplication after a failed reload");
"object calibration changed timing, pair identity, or multiplication after a failed reload");
trace = second;
correction.apply(trace);
require(trace.s21[0].re == 13 && trace.s21[0].im == 1 && trace.s21[1].re == -8 && trace.s21[1].im == -9 &&
@@ -406,19 +417,18 @@ void test_object_calibration(const Workspace& workspace) {
void test_object_calibration_pipeline(const Workspace& workspace) {
const ipc::SweepTraceBlock trace{.combo = {7, 3}, .frequency_hz = {1e9F, 2e9F},
.s11 = {{7, 3}, {8, 4}}, .s21 = {{2, 4}, {6, 8}}, .capture_start_ns = 5, .capture_end_ns = 15};
.s21 = {{2, 4}, {6, 8}}, .capture_start_ns = 5, .capture_end_ns = 15};
auto standard = trace;
standard.s21 = {{2, 0}, {2, 0}};
auto reference = trace;
reference.s21 = {{1, 2}, {2, 2}};
auto factors = trace;
factors.s21 = {{0, 2}, {5, 1}};
preprocessing::CalibrationMaster calibration(preprocessing::make_s21_through_calibrator());
calibration.load_s21_calibration_bundle(save_bundle(workspace, "pipeline_through.bin", {.traces = {standard}}));
preprocessing::ReferenceMaster references;
references.load_s21_reference_bundle(save_bundle(workspace, "pipeline_reference.bin", {.traces = {reference}}));
references.prepare_calibrated(calibration, {trace.combo});
config::RunConfig config;
config.preprocess.s21.calibration.bundle_path =
save_bundle(workspace, "pipeline_through.bin", {.traces = {standard}});
config.preprocess.s21.reference.bundle_path =
save_bundle(workspace, "pipeline_reference.bin", {.traces = {reference}});
config.run_combos = {trace.combo};
config.preprocess.s21.object_calibration.bundle_path =
save_bundle(workspace, "pipeline_object.bin", {.traces = {factors}});
@@ -426,7 +436,7 @@ void test_object_calibration_pipeline(const Workspace& workspace) {
RingName raw_name("_object_raw"), pre_name("_object_pre");
auto raw_ring = ipc::ShmRing::open_or_create(raw_name.name, 2, 4096);
auto pre_ring = ipc::ShmRing::open_or_create(pre_name.name, 2, 4096);
preprocessing::DataPreprocessor preprocessor(config, calibration, references, raw_ring, pre_ring);
preprocessing::DataPreprocessor preprocessor(config, raw_ring, pre_ring);
require(raw_ring.push(ipc::serialize_raw_collection({.collection_id = 41, .capture_start_ns = 5,
.capture_end_ns = 15, .traces = {trace}})), "test raw ring could not hold a sweep");
std::atomic<bool> stop{false};
@@ -449,8 +459,7 @@ void test_object_calibration_pipeline(const Workspace& workspace) {
"object calibration lost capture identity or timing");
require(output.s21[0].re == -2 && output.s21[0].im == 1 && output.s21[1].re == 0 && output.s21[1].im == 0,
"object calibration ran before existing preprocessing or was applied more than once");
require(output.s11[0].re == 7 && output.s11[0].im == 3 && output.s11[1].re == 0 && output.s11[1].im == 0,
"object calibration changed S11 or bypassed the notch filter");
}
void test_locator_packet_errors() {
@@ -506,14 +515,15 @@ int main(int argc, char** argv) {
try {
require(argc == 2, "Pass the simulator config fixture path");
Workspace workspace;
test_wire_decoders();
test_wire_format();
test_configuration(argv[1], workspace);
test_asset_reload_preserves_data(workspace);
test_object_calibration(workspace);
test_object_calibration_pipeline(workspace);
test_ring();
test_partial_driver_open();
test_acquisition_identity();
test_interruptible_wait();
test_cosine_notch_taper();
test_csv_failures(workspace);
test_locator_packet_errors();
test_unread_tty_shutdown(workspace);
+44 -45
View File
@@ -1,7 +1,9 @@
#include "sar3d_processor.hpp"
#include "../processing/processors/src/sar3d_gpu.hpp"
#include <algorithm>
#include <complex>
#include <iostream>
#include <vector>
#include <nlohmann/json.hpp>
using Json = nlohmann::json;
@@ -29,60 +31,57 @@ auto collection(const Json& source) -> ipc::PreprocessedCollection {
for (const auto& sample : complex_samples(entry.at("s21"))) {
trace.s21.push_back({sample.real(), sample.imag()});
}
if (result.traces.empty() || trace.capture_start_ns < result.capture_start_ns) {
result.capture_start_ns = trace.capture_start_ns;
}
result.capture_end_ns = std::max(result.capture_end_ns, trace.capture_end_ns);
result.traces.push_back(std::move(trace));
}
return result;
}
}
// Runs the sar3d processor once: the JSON request on stdin holds the antenna
// geometry, the live settings and the latest and previous collections; the
// published payloads go to stdout as JSON.
int main() {
try {
const Json input = Json::parse(std::cin);
Json output;
if (input.at("operation") == "processor") {
config::RunConfig config;
for (const auto& row : input.at("tx")) {
config.gpr.tx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
for (const auto& row : input.at("rx")) {
config.gpr.rx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
processing::ProcessingLiveConfig live;
live.gpr_start_freq_mhz = input.at("f_start").get<float>() / 1e6F;
live.gpr_stop_freq_mhz = input.at("f_stop").get<float>() / 1e6F;
live.gpr_min_depth_m = input.at("min_depth");
live.gpr_max_depth_m = input.at("max_depth");
live.gpr_speed_m_s = input.at("speed");
live.gpr_look_angle_deg = input.at("pitch");
live.sar3d_voxel_m = input.at("voxel");
live.sar3d_bp_ground_depth_m = input.at("ground");
live.sar3d_frame_radius = input.at("radius");
live.sar3d_comp_range_power = input.at("power");
live.sar3d_use_compensation = input.at("compensation");
live.sar3d_normalize_each_frame = input.at("normalize");
std::vector<ipc::PreprocessedCollection> previous;
for (const auto& member : input.at("previous")) {
previous.push_back(collection(member));
}
processing::Sar3dProcessor processor;
const auto result = processor.process_collection(config, collection(input.at("latest")), previous, live);
output["payloads"] = Json::array();
for (const auto& payload : result.collection_payloads) {
output["payloads"].push_back({{"name", payload.processing_name},
{"x", payload.image_x_axis}, {"y", payload.image_y_axis},
{"image", payload.image_values}, {"table", payload.table_values},
{"columns", payload.table_columns}});
}
} else {
processing::sar3d::Engine engine(1.5);
const auto samples = engine.inverse_dft(complex_samples(input.at("bands")),
input.at("points"), input.at("first_bin"), input.at("size"));
output["ascans"] = Json::array();
for (const auto& sample : samples) {
output["ascans"].push_back({sample.real(), sample.imag()});
}
config::RunConfig config;
config.rings.results.slot_size_bytes = input.value("slot", config.rings.results.slot_size_bytes);
for (const auto& row : input.at("tx")) {
config.gpr.tx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
std::cout << output << '\n';
for (const auto& row : input.at("rx")) {
config.gpr.rx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
processing::ProcessingLiveConfig live;
live.gpr_start_freq_mhz = input.at("f_start").get<float>() / 1e6F;
live.gpr_stop_freq_mhz = input.at("f_stop").get<float>() / 1e6F;
live.gpr_min_depth_m = input.at("min_depth");
live.gpr_max_depth_m = input.at("max_depth");
live.gpr_speed_m_s = input.at("speed");
live.gpr_look_angle_deg = input.at("pitch");
live.sar3d_voxel_m = input.at("voxel");
live.sar3d_bp_ground_depth_m = input.at("ground");
live.sar3d_frame_radius = input.at("radius");
live.sar3d_comp_range_power = input.at("power");
live.sar3d_use_compensation = input.at("compensation");
live.sar3d_normalize_each_frame = input.at("normalize");
std::vector<ipc::PreprocessedCollection> previous;
for (const auto& member : input.at("previous")) {
previous.push_back(collection(member));
}
processing::Sar3dProcessor processor;
const auto result = processor.process_collection(config, collection(input.at("latest")), previous, live);
Json payloads = Json::array();
for (const auto& payload : result.collection_payloads) {
payloads.push_back({{"name", payload.processing_name},
{"x", payload.image_x_axis}, {"y", payload.image_y_axis},
{"image", payload.image_values}, {"table", payload.table_values},
{"columns", payload.table_columns}});
}
std::cout << Json{{"payloads", payloads}} << '\n';
} catch (const std::exception& error) {
std::cout << Json{{"error", error.what()}} << '\n';
return 1;
@@ -1,136 +0,0 @@
// Test-only execution of the exact CUDA kernel source, one logical thread at a time.
// Linked solely into sar3d_math_fixture; never used as a production backend.
#include "../processing/processors/src/sar3d_gpu.hpp"
#include <algorithm>
#include <bit>
#include <climits>
#include <cmath>
#include <limits>
#include <stdexcept>
#include <string>
#define __global__
using std::isfinite;
using std::min;
struct Index { unsigned x; };
Index blockIdx{0}, blockDim{1}, threadIdx{0};
struct float2 { float x, y; };
auto make_float2(float x, float y) -> float2 { return {x, y}; }
void sincospif(float x, float* sine, float* cosine) {
*sine = std::sin(x * 3.14159265358979323846F);
*cosine = std::cos(x * 3.14159265358979323846F);
}
auto __powf(float x, float y) -> float { return std::pow(x, y); }
auto __float_as_uint(float x) -> unsigned int { return std::bit_cast<unsigned int>(x); }
auto __double2int_rn(double x) -> int { return static_cast<int>(std::nearbyint(x)); }
void atomicMax(unsigned int* destination, unsigned int value) { *destination = std::max(*destination, value); }
#include "sar3d_kernels.inc"
auto probe_ascan_upper_bound() -> float {
blockIdx.x = 0;
const double x[]{1073741824.0}, zero[]{0.0};
const ::Contribution contribution{{}, {}, 1.0F, 1.0F};
float volume = -1.0F;
::backproject(nullptr, INT_MAX, &contribution, 1, x, zero, zero, 1, 1, 1,
1.0, 0.0, 0.0, 0.0, 1.0, 1.0F, 0, 0.0F, 0.0F, &volume);
return volume;
}
namespace {
template <class Operation>
void each(int count, Operation operation) {
for (blockIdx.x = 0; blockIdx.x < static_cast<unsigned>(count); ++blockIdx.x) { operation(); }
}
auto float_pairs(std::span<const std::complex<float>> values) -> std::vector<float2> {
std::vector<float2> pairs;
pairs.reserve(values.size());
for (const auto value : values) { pairs.push_back({value.real(), value.imag()}); }
return pairs;
}
}
namespace radar::processing::sar3d {
namespace {
#include "sar3d_helpers.inc"
}
struct Engine::Impl { std::vector<float> taps; };
Engine::Engine(double sigma) : impl_(std::make_unique<Impl>()) { impl_->taps = gaussian_taps(sigma); }
Engine::~Engine() = default;
auto Engine::inverse_dft(std::span<const std::complex<float>> bands, std::size_t points,
std::size_t first_bin, std::size_t size) -> std::vector<std::complex<float>> {
if (points == 0U || bands.empty() || bands.size() % points != 0U || !std::has_single_bit(size) ||
size > kMaxIndexedItems || first_bin >= size || points > size - first_bin) {
throw std::runtime_error("sar3d invalid inverse FFT dimensions");
}
const auto count = checked_product(bands.size() / points, size, kMaxIndexedItems, "A-scan stack");
const auto input = float_pairs(bands);
std::vector<float2> output(count);
each(static_cast<int>(count), [&] {
::inverse_dft(input.data(), points, first_bin, size, count, output.data());
});
std::vector<std::complex<float>> result;
result.reserve(count);
for (const auto value : output) { result.emplace_back(value.x, value.y); }
return result;
}
auto Engine::project(const VolumeRequest& request) -> Projections {
const int nx = request.x.size(), ny = request.y.size(), nz = request.z.size();
const int plane = checked_product(nx, nz, kMaxIndexedItems, "grid plane");
const int voxels = checked_product(plane, ny, kMaxIndexedItems, "grid");
std::vector<::Contribution> contributions;
for (const auto& term : request.contributions) {
contributions.push_back(std::bit_cast<::Contribution>(term));
}
const auto ascans = float_pairs(request.ascans);
std::vector<float> volume(voxels), scratch(voxels), levels(ny);
std::vector<unsigned int> indices(ny);
each(voxels, [&] {
::backproject(ascans.data(), request.samples_per_ascan, contributions.data(), contributions.size(),
request.x.data(), request.y.data(), request.z.data(), nx, nz, voxels,
std::cos(request.pitch_rad), std::sin(request.pitch_rad), request.ground_depth_m,
request.min_depth_m, request.max_depth_m, request.samples_per_metre,
request.use_compensation, request.comp_range_power, request.comp_weight_max, volume.data());
});
each(ny, [&] { ::peak_planes(volume.data(), plane, ny, levels.data(), indices.data()); });
Projections raw;
choose_peak(raw, request, levels);
const int radius = impl_->taps.size() / 2;
each(voxels, [&] {
::smooth(volume.data(), scratch.data(), impl_->taps.data(), radius, nx, nz, voxels, 1);
});
each(voxels, [&] {
::smooth(scratch.data(), volume.data(), impl_->taps.data(), radius, nx, nz, voxels, 0);
});
each(ny, [&] { ::peak_planes(volume.data(), plane, ny, levels.data(), indices.data()); });
Projections result;
result.raw_level = raw.level;
choose_peak(result, request, levels, indices);
result.front.resize(ny * nx);
result.side.resize(ny * nz);
result.top.resize(plane);
const auto& view = request.oblique;
std::vector<unsigned int> oblique(checked_product(view.nu, view.nv, kMaxIndexedItems, "oblique image"));
each(result.front.size(), [&] {
::max_project(volume.data(), result.front.data(), result.front.size(), nx, plane, 1, nz, nx);
});
each(result.side.size(), [&] {
::max_project(volume.data(), result.side.data(), result.side.size(), nz, plane, nx, nx, 1);
});
each(result.top.size(), [&] {
::max_project(volume.data(), result.top.data(), result.top.size(), nx, nx, 1, ny, plane);
});
each(voxels, [&] {
::project_oblique(volume.data(), request.x.data(), request.y.data(), request.z.data(), nx, nz, voxels,
view.u[0], view.u[1], view.u[2], view.v[0], view.v[1], view.v[2], view.u0, view.v0,
1 / view.pixel_m, view.nu, view.nv, oblique.data());
});
result.oblique.reserve(oblique.size());
for (const auto value : oblique) { result.oblique.push_back(std::bit_cast<float>(value)); }
return result;
}
}
@@ -1,85 +0,0 @@
// Kernel arithmetic checks linked only with the host emulation fixture.
#include "../processing/processors/src/sar3d_gpu.hpp"
#include <cmath>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string_view>
extern "C" void peak_planes(const float*, int, int, float*, unsigned int*);
struct Index { unsigned x; };
extern Index blockIdx;
auto probe_ascan_upper_bound() -> float;
namespace {
void rejects_unsigned_padding() {
blockIdx.x = 0x80000000U;
peak_planes(nullptr, 1, 1, nullptr, nullptr);
}
void rejects_rounded_sample_bound() {
if (probe_ascan_upper_bound() != 0.0F) {
throw std::runtime_error("out-of-range float A-scan position was accepted");
}
}
void rejects_nan_peak() {
blockIdx.x = 0;
const float volume[]{std::numeric_limits<float>::quiet_NaN(), 0.0F};
float level = -1.0F;
unsigned int index = 0U;
peak_planes(volume, 2, 1, &level, &index);
if (std::isfinite(level)) { throw std::runtime_error("peak kernel silently ignored NaN"); }
}
void keeps_first_joint_peak() {
blockIdx.x = 0;
const float volume[]{0, 1, 0, 1, 0, 0};
float level = 0;
unsigned int index = 0U;
peak_planes(volume, 6, 1, &level, &index);
if (level != 1.0F || index != 1U) { throw std::runtime_error("joint peak tie chose a nonexistent voxel"); }
}
void rejects_finite_coherent_overflow() {
using namespace radar::processing::sar3d;
Engine engine(1.5);
const std::vector<double> x{-1, 1}, y{-.5, .5}, z{2, 3};
std::vector<Contribution> terms(3);
for (auto& term : terms) { term.weight = 1; term.inv_reference_ranges = 1; }
std::vector<std::complex<float>> scans(3 * 64);
for (int i = 0; i < 64; ++i) {
scans[i] = {1e38F, 0};
scans[64 + i] = {-1e38F, 0};
scans[128 + i] = {1, 0};
}
const VolumeRequest request{
.x = x, .y = y, .z = z, .min_depth_m = 1, .max_depth_m = 4,
.contributions = terms, .ascans = scans, .samples_per_ascan = 64, .samples_per_metre = 1,
.use_compensation = true, .comp_range_power = 1, .comp_weight_max = 5, .norm_edge_margin_m = .3,
.oblique = {.u = {1, 0, 0}, .v = {0, 0, 1}, .u0 = -1, .v0 = 2, .pixel_m = 1, .nu = 3, .nv = 2},
};
try {
(void)engine.project(request);
} catch (const std::runtime_error& error) {
if (std::string_view(error.what()).find("non-finite") == std::string_view::npos) { throw; }
return;
}
throw std::runtime_error("finite coherent overflow was silently accepted as no signal");
}
}
int main() {
try {
rejects_unsigned_padding();
rejects_rounded_sample_bound();
rejects_nan_peak();
keeps_first_joint_peak();
rejects_finite_coherent_overflow();
std::cout << "SAR CUDA kernel arithmetic regressions passed\n";
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
Binary file not shown.
+139
View File
@@ -0,0 +1,139 @@
"""The sar3d processor on the real CUDA backend: notebook reference and contracts.
Every case runs build/native_tests/sar3d_fixture (SAR3D_FIXTURE overrides it).
Without a usable GPU the processor publishes sar3d_status 3: then only that
warning is checked and the remaining tests are skipped. The notebook is needed
only to regenerate sar3d_reference.npz with generate_sar3d_reference.py.
"""
from __future__ import annotations
import copy
import json
import os
from pathlib import Path
import subprocess
import unittest
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
FIXTURE = Path(os.environ.get("SAR3D_FIXTURE", ROOT / "build/native_tests/sar3d_fixture"))
REFERENCE = Path(__file__).with_name("sar3d_reference.npz")
VIEWS = ("sar3d_front", "sar3d_side", "sar3d_top", "sar3d_oblique")
def run(request: dict) -> dict[str, dict]:
output = subprocess.run([str(FIXTURE)], input=json.dumps(request), text=True,
capture_output=True, timeout=30, check=True)
return {item["name"]: item for item in json.loads(output.stdout)["payloads"]}
def status(payloads: dict[str, dict]) -> tuple[int, str]:
table = payloads["sar3d_status"]["table"]
return int(table[0]), bytes(map(int, table[1:])).decode("utf-8")
def reference_cases(archive) -> list[dict]:
"""Requests of the reference archive, one per notebook parameter case."""
cases = []
for case in json.loads(archive["cases"].item()):
request = json.loads(archive["request"].item())
request.update(case["settings"])
for member, windows in zip([*request["previous"], request["latest"]], case["windows"], strict=True):
for trace, (start, end) in zip(member["traces"], windows, strict=True):
trace.update(start=start, end=end)
cases.append(request)
return cases
class Sar3dTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
with np.load(REFERENCE, allow_pickle=False) as archive:
cls.reference = {key: archive[key] for key in archive.files}
cls.cases = reference_cases(cls.reference)
cls.scene_payloads = run(cls.cases[0])
code, message = status(cls.scene_payloads)
cls.unavailable = message if code == 3 else None
def scene(self, **settings) -> dict:
if self.unavailable:
self.skipTest(self.unavailable)
request = copy.deepcopy(self.cases[0])
request.update(settings)
return request
def test_unavailable_gpu_is_a_visible_warning(self) -> None:
code, message = status(self.scene_payloads)
if self.unavailable:
self.assertIn("CUDA", message)
self.assertFalse(any(item["image"] for item in self.scene_payloads.values()))
else:
self.assertEqual(code, 0, message)
def test_cuda_matches_notebook(self) -> None:
if self.unavailable:
self.skipTest(self.unavailable)
for index, request in enumerate(self.cases):
with self.subTest(case=index):
payloads = run(request)
self.assertEqual(status(payloads)[0], 0, status(payloads))
for view, horizontal, vertical in (("front", "x", "y"), ("side", "z", "y"), ("top", "x", "z")):
payload = payloads[f"sar3d_{view}"]
expected = self.reference[f"case_{index}_{view}"]
np.testing.assert_allclose(payload["x"], self.reference[f"case_{index}_{horizontal}"],
rtol=2e-6, atol=1e-6)
np.testing.assert_allclose(payload["y"], self.reference[f"case_{index}_{vertical}"],
rtol=2e-6, atol=1e-6)
np.testing.assert_allclose(np.asarray(payload["image"]).reshape(expected.shape),
expected, rtol=2e-3, atol=3e-4)
peak = payloads["sar3d_peak"]["table"]
expected_peak = self.reference[f"case_{index}_peak"]
np.testing.assert_allclose(peak[1:], expected_peak[1:], rtol=0, atol=2e-6)
np.testing.assert_allclose(peak[0], expected_peak[0], rtol=2e-3, atol=0)
def test_invalid_input_is_an_error_with_a_reason(self) -> None:
def no_traces(request): request["latest"]["traces"] = []
def empty_window(request): request["latest"]["traces"][0]["end"] = request["latest"]["traces"][0]["start"]
def falling_frequency(request): request["latest"]["traces"][0]["frequency"].reverse()
for name, settings, damage in (
("no traces", {}, no_traces),
("empty capture window", {}, empty_window),
("falling frequency", {}, falling_frequency),
("grid beyond 32-bit indexing", {"voxel": 1e-8}, None),
("vertical look angle", {"pitch": 90}, None),
("result larger than the ring slot", {"slot": 1024}, None),
):
with self.subTest(name):
request = self.scene(**settings)
if damage:
damage(request)
code, message = status(run(request))
self.assertEqual(code, 2, message)
self.assertTrue(message)
def test_empty_signal_is_reported_without_a_peak(self) -> None:
request = self.scene(previous=[], radius=0)
for trace in request["latest"]["traces"]:
trace["s21"] = [[0, 0]] * len(trace["frequency"])
payloads = run(request)
self.assertEqual(status(payloads)[0], 1)
self.assertNotIn("sar3d_peak", payloads)
for name in VIEWS:
self.assertFalse(np.any(payloads[name]["image"]))
def test_weak_signal_stays_finite(self) -> None:
request = self.scene(previous=[], radius=0, normalize=False)
for trace in request["latest"]["traces"]:
trace["s21"] = (np.asarray(trace["s21"]) * 1e-38).tolist()
payloads = run(request)
self.assertEqual(status(payloads)[0], 0, status(payloads))
for name in VIEWS:
values = np.asarray(payloads[name]["image"])
self.assertTrue(np.isfinite(values).all())
self.assertGreater(values.max(), 0)
self.assertLess(values.max(), 1e-10)
if __name__ == "__main__":
unittest.main()
@@ -1,297 +0,0 @@
"""Compare the native SAR port with notebook cells 0/4, without dataset or GPU fixtures."""
from __future__ import annotations
import json
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
FIXTURE = ROOT / "build/native_tests/sar3d_math_fixture"
CUDA_FIXTURE = ROOT / "build/native_tests/sar3d_fixture"
C = 3e8
def smooth(volume):
# scipy gaussian_filter(sigma=(0,1.5,1.5), mode='reflect', truncate=4).
taps = np.exp(-.5 * (np.arange(-6, 7) / 1.5) ** 2)
taps /= taps.sum()
for axis in (1, 2):
volume = np.apply_along_axis(lambda row: np.convolve(
np.pad(row, 6, mode="symmetric"), taps, mode="valid"), axis, volume)
return volume
def native(request, executable=None):
run = subprocess.run([str(executable or FIXTURE)], input=json.dumps(request), text=True,
capture_output=True, timeout=30, check=False)
if not run.stdout:
raise AssertionError(run.stderr)
return json.loads(run.stdout)
def fixture(base=2**64 - 3_000_000_000):
request = dict(operation="processor", tx=[[0, -.4, 0, 0], [1, .1, .2, 0], [2, .45, .1, .05]],
rx=[[0, -.2, .1, 0], [1, .3, .35, .02]], f_start=1e9, f_stop=4e9,
min_depth=1., max_depth=3., speed=.4, pitch=13., ground=.3,
voxel=.4, radius=1, power=1.5, compensation=True, normalize=True)
members = []
frequency = np.linspace(1e9, 4e9, 64, dtype=np.float32)
for frame, offset in enumerate([0, 180_000_000, 510_000_000]):
traces = []
for tx in range(3):
for rx in range(2):
# Two transmitters interleave in one real capture window; the
# third has a longer independent segment, matching meta timing.
start = base + offset + (0 if tx < 2 else 70_000_000)
end = base + offset + (60_000_000 if tx < 2 else 165_000_001)
samples = ((1 + .1 * rx) * np.exp(-2j * np.pi * frequency *
(4.8 + .08 * tx - .03 * frame) / C) +
.35 * np.exp(-2j * np.pi * frequency * (5.6 - .05 * rx) / C)).astype(np.complex64)
traces.append(dict(input=rx, output=tx, start=start, end=end,
frequency=frequency.tolist(), s21=np.column_stack((samples.real, samples.imag)).tolist()))
members.append(dict(id=frame + 1, timestamp=base + offset, traces=traces))
request.update(previous=members[:-1], latest=members[-1])
return request
def oracle(request):
"""Notebook ground/frequency-domain mode, with actual per-trace capture windows."""
pitch = np.deg2rad(float(np.float32(request["pitch"])))
cos_p, sin_p = np.cos(pitch), np.sin(pitch)
speed, voxel, ground = (float(np.float32(request[key])) for key in ("speed", "voxel", "ground"))
axis = lambda lo, hi: np.linspace(lo, hi, max(round((hi - lo) / voxel) + 1, 2))
x, y = axis(-2, 2), axis(-.5, 2)
limits = [(depth + (height - ground) * sin_p) / cos_p
for height in (y[0], y[-1]) for depth in (request["min_depth"], request["max_depth"])]
z = axis(min(limits), max(limits))
xx, zz = np.meshgrid(x, z)
frames, centres = [], []
for member in request["previous"] + [request["latest"]]:
traces = member["traces"]
first, last = min(t["start"] for t in traces), max(t["end"] for t in traces)
centres.append((first + last) / 2 if first < 2**53 else (first, last))
ascans = []
for trace in traces:
frequency = np.asarray(trace["frequency"], dtype=np.float32)
samples = np.asarray(trace["s21"])
samples = samples[:, 0] + 1j * samples[:, 1]
mask = (frequency >= request["f_start"]) & (frequency <= request["f_stop"])
points = np.flatnonzero(mask)
stages = sorted({t["output"] for t in traces
if (t["start"], t["end"]) == (trace["start"], trace["end"])})
# Relative integer subtraction retains precision at uint64 limits.
t = ((trace["start"] - first) - .5 * (last - first)) * 1e-9
t += (len(stages) * points + stages.index(trace["output"])) * (
(trace["end"] - trace["start"]) * 1e-9 / len(stages) / len(frequency))
band = samples[mask] * np.exp(-4j * np.pi * speed * cos_p * frequency[mask] * t / C)
df = float(np.median(np.diff(frequency[mask].astype(float))))
k0, n = round(float(frequency[mask][0]) / df), len(band)
size = 2 ** (2 * (k0 + n - 1) - 1).bit_length() * 8
spectrum = np.zeros(size, dtype=complex)
spectrum[k0:k0+n] = band * np.hanning(n)
h = np.fft.ifft(spectrum)
dt = 1 / (size * df)
if request["normalize"]:
depth = np.arange(size) * dt * C / 2
gate = (depth >= request["min_depth"]) & (depth <= request["max_depth"])
scale = np.percentile(np.abs(h)[gate], 50)
h /= (scale if np.isfinite(scale) and scale > 1e-15 else 1) + 1e-15
ascans.append(h)
frames.append(ascans)
# All members use identical grids. Native centre trails latest by radius.
centre_index = max(len(frames) - 1 - request["radius"], 0)
def relative(a, b):
if isinstance(a, tuple):
return .5e-9 * ((a[0] - b[0]) + (a[1] - b[1]))
return 1e-9 * (a - b)
offsets = np.array([relative(t, centres[centre_index]) for t in centres])
farthest = np.max(np.abs(offsets))
weights = np.ones(len(frames)) if farthest <= 1e-12 else .8 + .1 * (1 + np.cos(np.pi * abs(offsets) / farthest))
acc = np.zeros((len(y), len(z), len(x)), dtype=complex)
count = np.zeros(acc.shape)
tx = np.asarray(request["tx"], dtype=np.float32)[:, 1:].astype(float)
rx = np.asarray(request["rx"], dtype=np.float32)[:, 1:].astype(float)
for scans, offset, weight in zip(frames, offsets, weights):
shift = np.array([0, speed * offset * sin_p, speed * offset * cos_p])
for iy, height in enumerate(y):
yr, zr = (height - ground) * cos_p + zz * sin_p, -(height - ground) * sin_p + zz * cos_p
depth_ok = (zr >= request["min_depth"]) & (zr <= request["max_depth"])
for it, antenna_t in enumerate(tx):
for ir, antenna_r in enumerate(rx):
def distance(antenna):
return np.sqrt((xx-antenna[0])**2 + (yr-antenna[1])**2 + (zr-antenna[2])**2)
rt, rr = distance(antenna_t + shift), distance(antenna_r + shift)
position = (rt + rr) / (C * dt)
h = scans[it * len(rx) + ir]
valid = depth_ok & (position <= len(h) - 1)
sample = np.interp(position, np.arange(len(h)), h)
if request["compensation"]:
point = np.array([.5 * (antenna_t[0] + antenna_r[0]),
(-ground + 5 * sin_p) / cos_p, 5])
ref = 1 / (np.linalg.norm(point-antenna_t) * np.linalg.norm(point-antenna_r) + 1e-12) + 1e-30
ratio = (1 / (rt * rr + 1e-12) + 1e-30) / ref
sample *= np.clip(1 / (ratio ** request["power"] + 1e-12), 0, 5)
acc[iy] += weight * np.where(valid, sample, 0)
count[iy] += weight * valid
eligible = (y >= y[0]+.3) & (y <= y[-1]-.3)
raw_volume = np.abs(acc / (count + 1e-12))
raw_level = raw_volume[eligible].max() + 1e-12
volume = smooth(raw_volume / raw_level)
smooth_level = volume[eligible].max() + 1e-12
level = raw_level * smooth_level
peak = np.unravel_index(np.argmax(np.where(eligible[:, None, None], volume, -1)), volume.shape)
volume /= smooth_level
iy, iz, ix = peak
eye = np.array([1.7, -2.2, .85])
forward = -eye / np.linalg.norm(eye)
right = np.cross(forward, [0, 0, 1])
right /= np.linalg.norm(right)
up = np.cross(right, forward)
u, v = right[[0, 2, 1]], up[[0, 2, 1]]
points = np.array(np.meshgrid(x, y, z, indexing="ij")).reshape(3, -1).T
us, vs = points @ u, points @ v
u0, v0 = us.min(), vs.min()
nu, nv = round((us.max() - u0) / voxel) + 1, round((vs.max() - v0) / voxel) + 1
oblique = np.zeros((nv, nu))
pu, pv = np.rint((us-u0)/voxel).astype(int), np.rint((vs-v0)/voxel).astype(int)
valid = (pu >= 0) & (pu < nu) & (pv >= 0) & (pv < nv)
np.maximum.at(oblique, (pv[valid], pu[valid]), volume.transpose(2, 0, 1).ravel()[valid])
return dict(x=x, y=y, z=z, volume=volume, level=level, peak=[x[ix], y[iy], z[iz]],
peak_index=peak, oblique=oblique, u=u0 + np.arange(nu)*voxel, v=v0 + np.arange(nv)*voxel)
class Sar3dOracleTest(unittest.TestCase):
def test_cuda_branch_cpp_syntax(self):
# Hardware-free name/type structure check. Variadic API placeholders do
# not validate the NVIDIA ABI or replace testing NVRTC on an actual GPU.
source_path = ROOT / "data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp"
source = source_path.read_text()
with tempfile.TemporaryDirectory(prefix="radar_cuda_syntax_") as directory:
path = Path(directory)
functions = set(re.findall(r"\b(cu[A-Z]\w*)\s*\(", source))
constants = set(re.findall(r"\bCU_DEVICE_ATTRIBUTE_\w+", source))
(path / "cuda.h").write_text(
"#pragma once\n#include <cstdint>\nenum CUresult {CUDA_SUCCESS=0};\n"
"using CUdevice=int;using CUdeviceptr=std::uintptr_t;using CUcontext=struct Context*;"
"using CUmodule=struct Module*;using CUfunction=struct Function*;\n" +
"".join(f"constexpr int {name}=0;\n" for name in constants) +
"".join(f"inline CUresult {name}(...) {{return CUDA_SUCCESS;}}\n" for name in functions))
functions = set(re.findall(r"\b(nvrtc[A-Z]\w*)\s*\(", source)) | {
"nvrtcGetCUBINSize", "nvrtcGetPTXSize", "nvrtcGetCUBIN", "nvrtcGetPTX"}
(path / "nvrtc.h").write_text(
"#pragma once\nenum nvrtcResult {NVRTC_SUCCESS=0};using nvrtcProgram=struct Program*;\n"
'inline const char* nvrtcGetErrorString(...) {return "stub";}\n' +
"".join(f"inline nvrtcResult {name}(...) {{return NVRTC_SUCCESS;}}\n"
for name in functions if name != "nvrtcGetErrorString"))
result = subprocess.run(["g++", "-std=c++20", "-pthread", "-fsyntax-only", "-Wall", "-Wextra",
"-Wpedantic", "-Werror", "-DSAR3D_WITH_CUDA", f"-I{path}", str(source_path)],
text=True, capture_output=True, timeout=30, check=False)
self.assertEqual(result.returncode, 0, result.stderr)
def ready(self, result):
payloads = {p["name"]: p for p in result["payloads"]}
status = payloads["sar3d_status"]["table"]
self.assertEqual(payloads["sar3d_status"]["columns"], len(status))
self.assertEqual(status[0], 0)
return payloads
def test_inverse_fft_matches_numpy(self):
random = np.random.default_rng(209)
bands = random.normal(size=(2, 48)) + 1j * random.normal(size=(2, 48))
result = native(dict(operation="ifft", bands=np.column_stack((bands.ravel().real, bands.ravel().imag)).tolist(),
points=48, first_bin=17, size=512))
actual = np.asarray(result["ascans"])
spectrum = np.zeros((2, 512), dtype=complex)
spectrum[:, 17:65] = bands.astype(np.complex64)
expected = np.fft.ifft(spectrum).ravel()
np.testing.assert_allclose(actual[:, 0] + 1j * actual[:, 1], expected, rtol=3e-4, atol=2e-7)
def test_production_backend_reports_cuda_warning(self):
result = native(fixture(), CUDA_FIXTURE)
payloads = {p["name"]: p for p in result["payloads"]}
status = payloads["sar3d_status"]["table"]
if status[0] == 3:
self.assertIn("CUDA", bytes(map(int, status[1:])).decode("utf-8"))
self.assertFalse(any(item["image"] for item in payloads.values()))
else:
# On a CUDA-equipped test host, also run the real compiled backend.
self.ready(result)
def test_motion_volume_matches_notebook(self):
for compensation, normalize in ((True, True), (True, False), (False, True), (False, False)):
with self.subTest(compensation=compensation, normalize=normalize):
request = fixture()
request.update(compensation=compensation, normalize=normalize)
payloads = self.ready(native(request))
expected = oracle(request)
for name, image, x, y in (
("sar3d_front", expected["volume"].max(axis=1), expected["x"], expected["y"]),
("sar3d_side", expected["volume"].max(axis=2), expected["z"], expected["y"]),
("sar3d_top", expected["volume"].max(axis=0), expected["x"], expected["z"]),
("sar3d_oblique", expected["oblique"], expected["u"], expected["v"])):
np.testing.assert_allclose(payloads[name]["x"], x, rtol=2e-6, atol=1e-6)
np.testing.assert_allclose(payloads[name]["y"], y, rtol=2e-6, atol=1e-6)
np.testing.assert_allclose(np.asarray(payloads[name]["image"]).reshape(image.shape), image,
rtol=2e-3, atol=3e-4)
np.testing.assert_allclose(payloads["sar3d_peak"]["table"][1:], expected["peak"], atol=2e-6)
np.testing.assert_allclose(payloads["sar3d_peak"]["table"][0], expected["level"], rtol=2e-3)
def test_errors_are_published(self):
for malformed in ("empty", "capture", "frequency", "axis", "angle"):
with self.subTest(malformed=malformed):
request = fixture()
if malformed == "empty":
request["latest"]["traces"] = []
elif malformed == "capture":
request["latest"]["traces"][0]["end"] = request["latest"]["traces"][0]["start"]
elif malformed == "frequency":
request["latest"]["traces"][0]["frequency"].reverse()
elif malformed == "axis":
request["voxel"] = 1e-8
else:
request["pitch"] = 90
result = native(request)
payloads = {p["name"]: p for p in result["payloads"]}
status = payloads["sar3d_status"]["table"]
self.assertEqual(payloads["sar3d_status"]["columns"], len(status))
self.assertEqual(status[0], 2)
self.assertTrue(bytes(map(int, status[1:])).decode())
def test_empty_signal_is_reported(self):
request = fixture()
request.update(previous=[], radius=0)
for trace in request["latest"]["traces"]:
trace["s21"] = [[0, 0]] * len(trace["frequency"])
payloads = {p["name"]: p for p in native(request)["payloads"]}
self.assertEqual(payloads["sar3d_status"]["table"][0], 1)
self.assertNotIn("sar3d_peak", payloads)
for name in ("sar3d_front", "sar3d_side", "sar3d_top", "sar3d_oblique"):
self.assertTrue(np.all(np.asarray(payloads[name]["image"]) == 0))
def test_invalid_fft_dimensions_are_rejected(self):
for points, first_bin, size in ((0, 0, 8), (1, 0, 7), (1, 8, 8), (2, 7, 8), (1, 0, 2**31)):
with self.subTest(points=points, first_bin=first_bin, size=size):
result = native(dict(operation="ifft", bands=[[1, 0]],
points=points, first_bin=first_bin, size=size))
self.assertIn("error", result)
def test_weak_signal_stays_finite(self):
request = fixture()
request.update(previous=[], radius=0, normalize=False)
for trace in request["latest"]["traces"]:
trace["s21"] = (np.asarray(trace["s21"]) * 1e-38).tolist()
payloads = self.ready(native(request))
for name in ("sar3d_front", "sar3d_side", "sar3d_top", "sar3d_oblique"):
values = np.asarray(payloads[name]["image"])
self.assertTrue(np.isfinite(values).all())
self.assertGreater(values.max(), 0)
self.assertLess(values.max(), 1e-10)
if __name__ == "__main__":
unittest.main()