Rework radar system
This commit is contained in:
@@ -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,
|
||||
|
||||
+5
-7
@@ -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();
|
||||
-4
@@ -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
|
||||
-51
@@ -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
|
||||
-21
@@ -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
|
||||
-106
@@ -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
|
||||
-494
@@ -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
|
||||
+13
-13
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+15
-21
@@ -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
|
||||
-24
@@ -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
|
||||
-241
@@ -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
|
||||
-148
@@ -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
|
||||
-394
@@ -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
|
||||
-197
@@ -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, ¤t) != 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
|
||||
|
||||
-103
@@ -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
|
||||
-183
@@ -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
|
||||
-53
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
Reference in New Issue
Block a user