Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -32,10 +32,7 @@ struct RadarConfig {
// Radar device identity and runtime mode.
std::string model = "librevna";
std::string serial{};
std::string remote_host = "127.0.0.1";
std::uint32_t remote_port = 50209;
DriverMode driver_mode = DriverMode::Mock;
float mock_signal_hz = 5'000'000.0F;
RadarSweepSettings sweep{};
};
@@ -134,7 +131,7 @@ struct GprRxGeometry {
};
struct GprConfig {
// Stable collection-level GPR processing settings.
// Initial GPR settings; the data_processor can override them through live config.
float relative_permittivity = 1.0F;
std::vector<GprTxGeometry> tx_geometry{};
std::vector<GprRxGeometry> rx_geometry{};
@@ -154,5 +151,6 @@ struct RunConfig {
};
[[nodiscard]] auto load_run_config(const std::string& path) -> RunConfig;
void validate_gpr_config(const GprConfig& config, const RunConfig& run_config);
} // namespace radar::config
@@ -0,0 +1,20 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <thread>
namespace radar::config {
// Returns true when stop interrupts the delay, checking at least every 50 ms.
inline auto wait_or_stop(std::uint32_t delay_ms, const std::atomic<bool>& stop) -> bool {
std::uint32_t waited = 0;
while (waited < delay_ms && !stop.load(std::memory_order_relaxed)) {
const auto chunk = std::min(50U, delay_ms - waited);
std::this_thread::sleep_for(std::chrono::milliseconds(chunk));
waited += chunk;
}
return stop.load(std::memory_order_relaxed);
}
} // namespace radar::config
@@ -65,7 +65,18 @@ using Json = nlohmann::json;
if (!value.is_number()) {
throw std::runtime_error("Expected JSON number at " + context);
}
return value.get<double>();
const auto numeric = value.get<double>();
if (!std::isfinite(numeric)) {
throw std::runtime_error("Expected finite number at " + context);
}
return numeric;
}
[[nodiscard]] auto number_to_f32(double value, const std::string& context) -> float {
if (!std::isfinite(value) || std::fabs(value) > std::numeric_limits<float>::max()) {
throw std::runtime_error("Value out of finite float32 range at " + context);
}
return static_cast<float>(value);
}
[[nodiscard]] auto number_to_u32(double value, const std::string& context) -> std::uint32_t {
@@ -74,7 +85,7 @@ using Json = nlohmann::json;
}
const auto rounded = std::round(value);
if (std::fabs(value - rounded) > 0.000001) {
if (value != rounded) {
throw std::runtime_error("Expected integer value at " + context);
}
return static_cast<std::uint32_t>(rounded);
@@ -82,7 +93,7 @@ using Json = nlohmann::json;
[[nodiscard]] auto number_to_i32(double value, const std::string& context) -> std::int32_t {
const auto rounded = std::round(value);
if (std::fabs(value - rounded) > 0.000001) {
if (value != rounded) {
throw std::runtime_error("Expected integer value at " + context);
}
@@ -135,7 +146,7 @@ using Json = nlohmann::json;
[[nodiscard]] auto optional_f32(const Json& object, const std::string& key, float fallback) -> float {
if (const auto* value = optional_field(object, key); value != nullptr) {
return static_cast<float>(as_number(*value, key));
return number_to_f32(as_number(*value, key), key);
}
return fallback;
}
@@ -173,8 +184,8 @@ using Json = nlohmann::json;
}
PreprocessNotchBandConfig band{};
band.low_hz = static_cast<float>(as_number((*band_array)[0], context + ".bands_hz[][0]"));
band.high_hz = static_cast<float>(as_number((*band_array)[1], context + ".bands_hz[][1]"));
band.low_hz = number_to_f32(as_number((*band_array)[0], context + ".bands_hz[][0]"), context);
band.high_hz = number_to_f32(as_number((*band_array)[1], context + ".bands_hz[][1]"), context);
notch.bands_hz.push_back(band);
}
}
@@ -242,6 +253,9 @@ using Json = nlohmann::json;
}
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");
}
@@ -297,32 +311,6 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
}
}
void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) {
if (!(config.relative_permittivity > 0.0F)) {
throw std::runtime_error("gpr.relative_permittivity must be > 0");
}
std::unordered_set<std::uint32_t> seen_tx_positions{};
for (const auto& entry : config.tx_geometry) {
if (entry.output_pos >= run_config.output_switch.positions) {
throw std::runtime_error("gpr.tx_geometry output_pos is out of range");
}
if (!seen_tx_positions.insert(entry.output_pos).second) {
throw std::runtime_error("gpr.tx_geometry contains duplicate output_pos");
}
}
std::unordered_set<std::uint32_t> seen_rx_positions{};
for (const auto& entry : config.rx_geometry) {
if (entry.input_pos >= run_config.input_switch.positions) {
throw std::runtime_error("gpr.rx_geometry input_pos is out of range");
}
if (!seen_rx_positions.insert(entry.input_pos).second) {
throw std::runtime_error("gpr.rx_geometry contains duplicate input_pos");
}
}
}
[[nodiscard]] auto parse_gpr_config(const Json& object, const RunConfig& run_config) -> GprConfig {
const auto* gpr_obj = as_object(object, "gpr");
GprConfig config{};
@@ -406,6 +394,9 @@ void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) {
if (endpoint.slot_size_bytes == 0U) {
throw std::runtime_error("Ring slot_size_bytes must be > 0 for " + key);
}
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() || endpoint.name.front() != '/') {
throw std::runtime_error("Ring name must start with '/' for " + key);
}
@@ -415,6 +406,39 @@ void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) {
} // namespace
void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) {
if (!std::isfinite(config.relative_permittivity) || !(config.relative_permittivity > 0.0F)) {
throw std::runtime_error("gpr.relative_permittivity must be > 0");
}
std::unordered_set<std::uint32_t> seen_tx_positions{};
for (const auto& entry : config.tx_geometry) {
if (!std::isfinite(entry.x_m) || !std::isfinite(entry.y_m) || !std::isfinite(entry.z_m)) {
throw std::runtime_error("gpr.tx_geometry coordinates must be finite");
}
if (entry.output_pos >= run_config.output_switch.positions) {
throw std::runtime_error("gpr.tx_geometry output_pos is out of range");
}
if (!seen_tx_positions.insert(entry.output_pos).second) {
throw std::runtime_error("gpr.tx_geometry contains duplicate output_pos");
}
}
std::unordered_set<std::uint32_t> seen_rx_positions{};
for (const auto& entry : config.rx_geometry) {
if (!std::isfinite(entry.x_m) || !std::isfinite(entry.y_m) || !std::isfinite(entry.z_m)) {
throw std::runtime_error("gpr.rx_geometry coordinates must be finite");
}
if (entry.input_pos >= run_config.input_switch.positions) {
throw std::runtime_error("gpr.rx_geometry input_pos is out of range");
}
if (!seen_rx_positions.insert(entry.input_pos).second) {
throw std::runtime_error("gpr.rx_geometry contains duplicate input_pos");
}
}
}
auto load_run_config(const std::string& path) -> RunConfig {
std::ifstream stream(path);
if (!stream.is_open()) {
@@ -440,30 +464,21 @@ auto load_run_config(const std::string& path) -> RunConfig {
const auto* radar_obj = as_object(required_field(*root_obj, "radar"), "radar");
config.radar.model = optional_string(*radar_obj, "model", "librevna");
config.radar.serial = optional_string(*radar_obj, "serial", "");
config.radar.remote_host = optional_string(*radar_obj, "remote_host", "127.0.0.1");
config.radar.remote_port = optional_u32(*radar_obj, "remote_port", 50209);
if (config.radar.remote_host.empty()) {
throw std::runtime_error("radar.remote_host must not be empty");
}
if (config.radar.remote_port == 0U || config.radar.remote_port > 65535U) {
throw std::runtime_error("radar.remote_port must be in 1..65535");
}
config.radar.driver_mode = parse_driver_mode(optional_string(*radar_obj, "driver_mode", "mock"));
config.radar.mock_signal_hz = optional_f32(*radar_obj, "mock_signal_hz", 5'000'000.0F);
const auto* sweep_obj = as_object(required_field(*radar_obj, "sweep"), "radar.sweep");
config.radar.sweep.start_hz =
static_cast<float>(as_number(required_field(*sweep_obj, "start_hz"), "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 =
static_cast<float>(as_number(required_field(*sweep_obj, "stop_hz"), "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 = static_cast<float>(
as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz")
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);
@@ -2,6 +2,7 @@
#include <cstddef>
#include <cstdint>
#include <functional>
#include <span>
#include <string>
#include <unordered_map>
@@ -26,7 +27,8 @@ struct ComboKey {
struct ComboKeyHash {
[[nodiscard]] auto operator()(const ComboKey& key) const noexcept -> std::size_t {
return (static_cast<std::size_t>(key.input_pos) << 32U) ^ static_cast<std::size_t>(key.output_pos);
const auto packed = (static_cast<std::uint64_t>(key.input_pos) << 32U) | key.output_pos;
return std::hash<std::uint64_t>{}(packed);
}
};
@@ -53,6 +53,7 @@ class ShmRing {
[[nodiscard]] auto slot_stride_bytes() const -> std::size_t;
[[nodiscard]] auto slot_header(std::uint64_t sequence) const -> SlotHeader*;
[[nodiscard]] auto slot_payload(SlotHeader* slot) const -> std::uint8_t*;
void advance_read_seq(std::uint64_t target);
void close();
static void validate_name(const std::string& name);
@@ -95,7 +95,7 @@ class BinaryReader {
private:
void ensure_available(std::size_t size) const {
if (offset_ + size > bytes_.size()) {
if (size > bytes_.size() - offset_) {
throw std::runtime_error("Unexpected end of serialized payload");
}
}
@@ -132,9 +132,8 @@ void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
// request gigabytes and crash the process (length_error/bad_alloc/OOM-kill). The
// payload itself is already bounded by the ring slot size, so any count whose
// minimum encoding cannot fit in the bytes still available is structurally invalid.
void require_count_fits(std::uint32_t count, std::size_t min_bytes_each, BinaryReader& reader) {
if (min_bytes_each != 0U
&& static_cast<std::uint64_t>(count) * min_bytes_each > reader.remaining_bytes()) {
void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryReader& reader) {
if (min_bytes_each != 0U && count > reader.remaining_bytes() / min_bytes_each) {
throw std::runtime_error("Declared element count exceeds remaining payload bytes");
}
}
@@ -257,6 +256,7 @@ 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);
@@ -296,10 +296,12 @@ 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>(x_count) * static_cast<std::size_t>(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>());
@@ -307,7 +309,7 @@ auto read_image_result_payload(BinaryReader& reader, ResultPayload* payload) ->
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 < static_cast<std::size_t>(x_count) * static_cast<std::size_t>(y_count); ++index) {
for (std::size_t index = 0; index < value_count; ++index) {
payload->image_values.push_back(reader.read<float>());
}
}
@@ -334,9 +336,14 @@ 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>(column_count) * static_cast<std::size_t>(row_count));
for (std::size_t index = 0; index < static_cast<std::size_t>(column_count) * static_cast<std::size_t>(row_count); ++index) {
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>());
}
}
@@ -402,6 +409,7 @@ void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
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));
@@ -478,11 +486,13 @@ auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> Resul
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));
@@ -30,7 +30,7 @@ struct alignas(64) ShmRing::Header {
std::atomic<std::uint64_t> dropped{};
};
struct alignas(16) ShmRing::SlotHeader {
struct alignas(8) ShmRing::SlotHeader {
std::uint32_t payload_size = 0;
std::uint32_t reserved = 0;
std::uint64_t sequence = 0;
@@ -42,6 +42,7 @@ constexpr std::string_view kRingMagic = "RDRRING2";
constexpr std::uint32_t kRingVersion = 1;
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;
@@ -134,6 +135,22 @@ auto ShmRing::open_or_create(
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);
bool created = false;
int fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0660);
@@ -149,13 +166,28 @@ auto ShmRing::open_or_create(
ScopedFd scoped_fd(fd);
const auto slot_stride = sizeof(SlotHeader) + static_cast<std::size_t>(slot_size_bytes);
const auto mapped_size = sizeof(Header) + slot_stride * capacity;
if (created) {
if (ftruncate(fd, static_cast<off_t>(mapped_size)) != 0) {
throw errno_message("Failed to resize shared memory ring", name);
}
} else {
// shm_open publishes the name before the creator calls ftruncate. Mapping
// that zero/short file and reading its header would terminate us with SIGBUS.
const auto deadline = std::chrono::steady_clock::now() + kHeaderInitWaitTimeout;
while (true) {
struct stat info {};
if (fstat(fd, &info) != 0) {
throw errno_message("Failed to stat shared memory ring", name);
}
if (info.st_size >= 0 && static_cast<std::size_t>(info.st_size) >= mapped_size) {
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
}
std::this_thread::sleep_for(kHeaderInitPollInterval);
}
}
void* mapped = mmap(nullptr, mapped_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
@@ -167,7 +199,9 @@ auto ShmRing::open_or_create(
auto* header = static_cast<Header*>(mapped);
if (created) {
std::memset(mapped, 0, mapped_size);
// 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;
@@ -319,14 +353,13 @@ auto ShmRing::push(std::span<const std::uint8_t> payload) -> bool {
}
auto* slot = slot_header(write_seq);
// Seqlock publish: write the payload + size FIRST, then store the slot sequence
// LAST (after a release fence). A consumer that observes sequence == write_seq+1
// is therefore guaranteed a fully-written payload. (Publishing the sequence before
// the copy, as before, let a consumer copy a half-written slot.)
// Invalidate the old publication before overwriting its bytes: a slow consumer
// can otherwise accept a mixture of old and new data under the old sequence.
std::atomic_ref(slot->sequence).store(0U, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_seq_cst);
slot->payload_size = static_cast<std::uint32_t>(payload.size());
std::memcpy(slot_payload(slot), payload.data(), payload.size());
std::atomic_thread_fence(std::memory_order_release);
slot->sequence = write_seq + 1U;
std::atomic_ref(slot->sequence).store(write_seq + 1U, std::memory_order_release);
std::atomic_thread_fence(std::memory_order_release);
header_->write_seq.store(write_seq + 1U, std::memory_order_release);
@@ -345,9 +378,9 @@ auto ShmRing::pop(std::vector<std::uint8_t>& payload) -> bool {
}
auto* slot = slot_header(read_seq);
if (slot->sequence != read_seq + 1U) {
if (std::atomic_ref(slot->sequence).load(std::memory_order_acquire) != read_seq + 1U) {
// Slot not published for this seq, or already overwritten (lapped). Resync.
header_->read_seq.store(write_seq, std::memory_order_release);
advance_read_seq(write_seq);
return false;
}
@@ -355,7 +388,7 @@ auto ShmRing::pop(std::vector<std::uint8_t>& payload) -> bool {
if (payload_size > header_->slot_size_bytes) {
// Corrupt/torn slot: skip it (resync) rather than throw — a single bad slot
// must never abort the long-running consumer.
header_->read_seq.store(write_seq, std::memory_order_release);
advance_read_seq(write_seq);
return false;
}
@@ -366,15 +399,21 @@ auto ShmRing::pop(std::vector<std::uint8_t>& payload) -> bool {
// Seqlock verify: if the slot sequence changed during the copy, the producer
// lapped us mid-read and the payload is torn — discard and resync.
std::atomic_thread_fence(std::memory_order_acquire);
if (slot->sequence != read_seq + 1U) {
header_->read_seq.store(write_seq, std::memory_order_release);
if (std::atomic_ref(slot->sequence).load(std::memory_order_acquire) != read_seq + 1U) {
advance_read_seq(write_seq);
return false;
}
header_->read_seq.store(read_seq + 1U, std::memory_order_release);
advance_read_seq(read_seq + 1U);
return true;
}
void ShmRing::advance_read_seq(std::uint64_t target) {
auto current = header_->read_seq.load(std::memory_order_relaxed);
while (current < target && !header_->read_seq.compare_exchange_weak(
current, target, std::memory_order_release, std::memory_order_relaxed)) {}
}
auto ShmRing::slot_stride_bytes() const -> std::size_t {
return sizeof(SlotHeader) + header_->slot_size_bytes;
}
@@ -422,18 +461,18 @@ void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, c
if (slot_size_bytes == 0U) {
throw std::runtime_error("Shared memory ring slot size is zero for " + name);
}
if (slot_size_bytes % alignof(SlotHeader) != 0U) {
throw std::runtime_error("Shared memory ring slot size must be a multiple of 8 for " + name);
}
constexpr auto kMax = std::numeric_limits<std::size_t>::max();
const auto slot_stride = sizeof(SlotHeader) + static_cast<std::size_t>(slot_size_bytes);
// Guard each multiply/add against std::size_t overflow before computing expected_size.
if (slot_stride > kMax / static_cast<std::size_t>(capacity)) {
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()) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
const auto slots_size = slot_stride * static_cast<std::size_t>(capacity);
if (slots_size > kMax - sizeof(Header)) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
const auto expected_size = sizeof(Header) + slots_size;
if (mapped_size < expected_size) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
@@ -27,9 +27,7 @@ public:
void initialize_live_plot() const;
void initialize_csv(std::size_t channel_count, bool has_di1_trace) const;
void append_csv_packet(const CapturePacket& packet,
double frame_freq_hz,
std::size_t& global_frame_index) const;
void append_csv_packet(const CapturePacket& packet) const;
void update_live_plot(const CapturePacket& packet,
std::size_t packets_seen,
@@ -234,12 +234,20 @@ std::string csv_spool_path(const std::string& csv_path) {
return replace_extension(csv_path, ".capture_spool.bin");
}
void finish_file(std::ofstream& stream, const std::string& path) {
stream.close();
if (!stream) {
fail_write("Cannot finish writing file: " + path);
}
}
void write_text_file(const std::string& path, const std::string& text) {
std::ofstream file(path, std::ios::binary);
if (!file) {
fail_write("Cannot open file for writing: " + path);
}
file << text;
finish_file(file, path);
}
void write_live_data_script(const std::string& path, const std::string& data_json) {
@@ -584,6 +592,7 @@ void write_live_html_document(const std::string& path, const std::string& data_s
<< " </script>\n"
<< "</body>\n"
<< "</html>\n";
finish_file(html, path);
}
[[noreturn]] void fail_write(const std::string& message) {
@@ -629,11 +638,14 @@ void CaptureFileWriter::initialize_live_plot() const {
}
void CaptureFileWriter::initialize_csv(std::size_t channel_count, bool has_di1_trace) const {
if (channel_count != 1U && channel_count != 2U) {
fail_write("Capture channel count must be 1 or 2");
}
std::ofstream csv_file(csv_path_, std::ios::binary | std::ios::trunc);
if (!csv_file) {
fail_write("Cannot prepare CSV output file: " + csv_path_);
}
csv_file << "";
finish_file(csv_file, csv_path_);
const std::string spool_path = csv_spool_path(csv_path_);
std::ofstream spool(spool_path, std::ios::binary | std::ios::trunc);
@@ -647,16 +659,15 @@ void CaptureFileWriter::initialize_csv(std::size_t channel_count, bool has_di1_t
static_cast<uint32_t>(has_di1_trace ? 1U : 0U)
};
spool.write(reinterpret_cast<const char*>(&header), sizeof(header));
if (!spool) {
fail_write("Cannot initialize CSV spool: " + spool_path);
}
finish_file(spool, spool_path);
}
void CaptureFileWriter::append_csv_packet(const CapturePacket& packet,
double frame_freq_hz,
std::size_t& global_frame_index) const {
(void) frame_freq_hz;
(void) global_frame_index;
void CaptureFileWriter::append_csv_packet(const CapturePacket& packet) const {
if ((packet.channel_count != 1U && packet.channel_count != 2U)
|| (packet.channel_count == 2U && packet.ch1.size() != packet.ch2.size())
|| (packet.has_di1_trace && packet.di1.size() != packet.ch1.size())) {
fail_write("Capture packet channel lengths are inconsistent");
}
const std::string spool_path = csv_spool_path(csv_path_);
std::ofstream spool(spool_path, std::ios::binary | std::ios::app);
@@ -689,9 +700,7 @@ void CaptureFileWriter::append_csv_packet(const CapturePacket& packet,
static_cast<std::streamsize>(frames * sizeof(uint8_t)));
}
}
if (!spool) {
fail_write("Cannot append packet to CSV spool: " + spool_path);
}
finish_file(spool, spool_path);
}
void CaptureFileWriter::update_live_plot(const CapturePacket& packet,
@@ -763,7 +772,7 @@ void CaptureFileWriter::finalize_csv_from_spool(double frame_freq_hz) const {
CsvSpoolPacketHeader packet_header{};
spool.read(reinterpret_cast<char*>(&packet_header), sizeof(packet_header));
if (!spool) {
if (spool.eof()) {
if (spool.eof() && spool.gcount() == 0) {
break;
}
fail_write("Cannot read packet header from CSV spool: " + spool_path);
@@ -773,6 +782,17 @@ void CaptureFileWriter::finalize_csv_from_spool(double frame_freq_hz) const {
const std::size_t channel_count = static_cast<std::size_t>(packet_header.channel_count);
const std::size_t frames = static_cast<std::size_t>(packet_header.frame_count);
const bool has_di1_trace = packet_header.has_di1_trace != 0U;
if (channel_count != 1U && channel_count != 2U) {
fail_write("Invalid channel count in CSV spool: " + spool_path);
}
const auto data_position = spool.tellg();
spool.seekg(0, std::ios::end);
const auto remaining = spool.tellg() - data_position;
spool.seekg(data_position);
const auto bytes_per_frame = channel_count * sizeof(double) + (has_di1_trace ? 1U : 0U);
if (!spool || remaining < 0 || packet_header.frame_count > static_cast<uint64_t>(remaining) / bytes_per_frame) {
fail_write("Truncated or invalid packet data in CSV spool: " + spool_path);
}
if (!header_written) {
csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v";
@@ -839,6 +859,13 @@ void CaptureFileWriter::finalize_csv_from_spool(double frame_freq_hz) const {
}
csv.flush();
if (!csv) {
fail_write("Cannot flush CSV output; capture spool retained: " + csv_path_);
}
csv.close();
if (!csv) {
fail_write("Cannot close CSV output; capture spool retained: " + csv_path_);
}
std::remove(spool_path.c_str());
}
@@ -1016,4 +1043,5 @@ void CaptureFileWriter::write_svg(const std::vector<CapturePacket>& packets,
<< "\" font-size=\"14\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#203040\">DI1</text>\n";
}
file << "</svg>\n";
finish_file(file, svg_path_);
}
@@ -156,7 +156,8 @@ uint32_t parse_u32(const std::string& text, const std::string& field_name) {
const std::string clean = trim_copy(text);
char* end = nullptr;
const auto value = std::strtoull(clean.c_str(), &end, 0);
if ((end == clean.c_str()) || (*end != '\0') || (value > std::numeric_limits<uint32_t>::max())) {
if ((!clean.empty() && clean.front() == '-') || (end == clean.c_str())
|| (*end != '\0') || (value > std::numeric_limits<uint32_t>::max())) {
fail("Invalid integer for " + field_name + ": " + text);
}
return static_cast<uint32_t>(value);
@@ -191,15 +192,6 @@ uint32_t parse_ipv4(const std::string& text) {
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
}
std::string ipv4_to_string(uint32_t ip_addr) {
std::ostringstream out;
out << ((ip_addr >> 24) & 0xFF) << '.'
<< ((ip_addr >> 16) & 0xFF) << '.'
<< ((ip_addr >> 8) & 0xFF) << '.'
<< (ip_addr & 0xFF);
return out.str();
}
uint32_t parse_range(const std::string& text) {
const double value = parse_double(text, "range");
if (std::fabs(value - 10.0) < 1e-9) {
@@ -2033,8 +2025,14 @@ int run(const Config& cfg) {
}
expect_ok(api, api.StreamsEnable(device.hnd, enabled_streams), "Enable data streams");
const double target_frame_count = (cfg.duration_ms / 1000.0) * actual_frame_freq_hz;
if (!std::isfinite(target_frame_count) || target_frame_count < 0.0
|| target_frame_count >= static_cast<double>(std::numeric_limits<long long>::max())
|| target_frame_count > static_cast<double>(std::numeric_limits<std::size_t>::max())) {
fail("Capture duration and frame clock produce an unsupported frame count");
}
const std::size_t target_frames = std::max<std::size_t>(
1, static_cast<std::size_t>(std::llround((cfg.duration_ms / 1000.0) * actual_frame_freq_hz)));
1, static_cast<std::size_t>(std::llround(target_frame_count)));
std::cout << "Capture settings:\n"
<< " clock source: " << sync_mode_to_string(cfg.sync_mode, false) << "\n"
@@ -2306,7 +2304,6 @@ int run(const Config& cfg) {
if (!fast_tty_avg_stream_mode) {
current_packet.reset(target_frames, cfg.channel_count);
}
std::size_t csv_global_frame_index = 0;
std::size_t packet_avg_steps = 0;
std::size_t fast_packet_frames = 0;
uint64_t packet_clock_count = 0;
@@ -2776,7 +2773,7 @@ int run(const Config& cfg) {
}
std::cout << "\n";
writer->append_csv_packet(packet, actual_frame_freq_hz, csv_global_frame_index);
writer->append_csv_packet(packet);
packets.push_back(std::move(packet));
if ((cfg.svg_history_packets != 0U) && (packets.size() > cfg.svg_history_packets)) {
packets.pop_front();
@@ -196,7 +196,7 @@ TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity
: path_(std::move(path)),
impl_(std::make_unique<Impl>(ring_capacity_bytes)) {
if (is_character_device_path(path_)) {
impl_->fd = ::open(path_.c_str(), O_WRONLY | O_NOCTTY);
impl_->fd = ::open(path_.c_str(), O_WRONLY | O_NOCTTY | O_NONBLOCK);
if (impl_->fd < 0) {
throw std::runtime_error(io_error("Cannot open tty output", path_));
}
@@ -233,7 +233,20 @@ TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity
}
}
impl_->worker = std::thread([this]() { worker_loop(); });
try {
const auto flags = ::fcntl(impl_->fd, F_GETFL);
if (flags < 0 || ::fcntl(impl_->fd, F_SETFL, flags | O_NONBLOCK) < 0) {
throw std::runtime_error(io_error("Cannot make tty output nonblocking", path_));
}
impl_->worker = std::thread([this]() { worker_loop(); });
} catch (...) {
if (impl_->owns_link) {
::unlink(path_.c_str());
}
close_fd_if_open(impl_->slave_fd);
close_fd_if_open(impl_->fd);
throw;
}
}
TtyProtocolWriter::~TtyProtocolWriter() {
@@ -363,12 +376,12 @@ void TtyProtocolWriter::shutdown() {
impl_->stop_requested = true;
}
close_fd_if_open(impl_->fd);
impl_->data_ready_cv.notify_all();
if (impl_->worker.joinable()) {
impl_->worker.join();
}
close_fd_if_open(impl_->fd);
}
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
@@ -402,6 +415,12 @@ void TtyProtocolWriter::worker_loop() {
const std::uint8_t* bytes = write_batch.data();
std::size_t remaining = batch_count * kFrameByteCount;
while (remaining != 0U) {
{
std::lock_guard<std::mutex> lock(impl_->mutex);
if (impl_->stop_requested) {
return;
}
}
const ssize_t written = ::write(impl_->fd, bytes, remaining);
if (written < 0) {
if (errno == EINTR) {
@@ -16,6 +16,9 @@ struct ChannelTrace {
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);
@@ -12,6 +12,7 @@
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include "calibrator_interface.hpp"
@@ -59,6 +60,8 @@ void validate_raw_trace_layout(const ipc::SweepTraceBlock& trace, const std::str
}
}
} // namespace
void validate_channel_trace_layout(
const ChannelTrace& trace,
const std::string& trace_label,
@@ -69,6 +72,12 @@ void validate_channel_trace_layout(
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 {
@@ -77,6 +86,9 @@ void validate_channel_trace_layout(
}
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);
@@ -87,6 +99,8 @@ void validate_channel_trace_layout(
return true;
}
namespace {
void ensure_frequency_axis_match(
std::span<const float> expected,
std::span<const float> actual,
@@ -122,6 +136,9 @@ void ensure_frequency_axis_match(
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;
@@ -275,8 +292,6 @@ void ensure_combo_coverage(
} // namespace
void S21CalibrationBundle::load(const std::string& path) {
traces_by_combo_.clear();
if (path.empty()) {
throw std::runtime_error("S21 calibration bundle path must not be empty");
}
@@ -311,8 +326,6 @@ auto S21CalibrationBundle::resolve(const ipc::ComboKey& combo) const -> const Ch
}
void S21ReferenceBundle::load(const std::string& path) {
traces_by_combo_.clear();
if (path.empty()) {
throw std::runtime_error("S21 reference bundle path must not be empty");
}
@@ -337,9 +350,8 @@ void S11CalibrationBundle::load(
const std::string& short_path,
const std::string& load_path
) {
coefficients_by_combo_.clear();
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;
@@ -357,13 +369,15 @@ void S11CalibrationBundle::load(
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");
coefficients_by_combo_.reserve(open_traces.size());
decltype(coefficients_by_combo_) coefficients;
coefficients.reserve(open_traces.size());
for (const auto& [combo, open_trace] : open_traces) {
coefficients_by_combo_.insert_or_assign(
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 {
@@ -422,9 +436,8 @@ auto S11CalibrationBundle::resolve(const ipc::ComboKey& combo) const -> const Co
}
void S11ReferenceBundle::load(const std::string& path) {
traces_by_combo_.clear();
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;
@@ -1,14 +1,13 @@
#include "data_preprocessor.hpp"
#include "runtime_wait.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <span>
#include <stdexcept>
#include <string_view>
#include <thread>
#include <vector>
namespace radar::preprocessing {
@@ -117,7 +116,6 @@ auto DataPreprocessor::try_pop_raw_collection(
ipc::RawSweepCollection* out_collection
) -> bool {
if (!raw_ring_.pop(serialized_raw)) {
std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms));
return false;
}
@@ -145,6 +143,7 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
while (!stop_requested.load(std::memory_order_relaxed)) {
try {
if (!try_pop_raw_collection(serialized_raw, &raw_collection)) {
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
continue;
}
const auto preprocessed_collection = preprocess_collection(raw_collection);
@@ -174,7 +173,7 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
// 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.
std::this_thread::sleep_for(std::chrono::milliseconds(std::max<int>(1, config_.runtime.idle_sleep_ms)));
(void)config::wait_or_stop(std::max(1U, config_.runtime.idle_sleep_ms), stop_requested);
}
}
}
@@ -1,7 +1,5 @@
#include "reference_master.hpp"
#include <algorithm>
#include <cmath>
#include <span>
#include <stdexcept>
#include <string>
@@ -15,41 +13,10 @@
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);
}
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)
);
}
}
[[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) {
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 ReferenceMaster::load_s21_reference_bundle(const std::string& path) {
@@ -54,6 +54,7 @@ class DataProcessor {
void publish_locator(const ipc::ResultCollection& collection, const ProcessingLiveConfig& live_config);
const config::RunConfig& config_;
config::RunConfig processing_config_;
ipc::ShmRing& preprocessed_ring_;
ipc::ShmRing& results_ring_;
ProcessorRegistry processors_{};
@@ -2,9 +2,12 @@
#include <chrono>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "run_config.hpp"
namespace radar::processing {
enum class HistoryCommand {
@@ -34,12 +37,14 @@ struct ProcessingLiveConfig {
float pass_through_y_max_db = 0.0F;
std::string bscan_axis = "abs";
std::string bscan_channel = "s21";
float bscan_cut_m = 0.824F;
float bscan_max_depth_m = 1.0F;
float bscan_cut_m = 0.0F;
float bscan_max_depth_m = 8.0F;
float bscan_gain = 1.0F;
float bscan_start_freq_mhz = 100.0F;
float bscan_stop_freq_mhz = 8800.0F;
LegacyGprMode legacy_gpr_mode = LegacyGprMode::Point;
// Absent in legacy live files: use the initial run_config geometry and medium.
std::optional<config::GprConfig> gpr{};
std::vector<std::uint32_t> gpr_input_positions{};
std::vector<std::uint32_t> gpr_output_positions{};
float gpr_min_depth_m = 2.0F;
@@ -108,11 +113,12 @@ struct ProcessingLiveConfig {
bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None;
std::uint64_t settings_revision = 0;
};
class ProcessingLiveConfigLoader {
public:
explicit ProcessingLiveConfigLoader(std::string path);
ProcessingLiveConfigLoader(std::string path, const config::RunConfig& run_config);
[[nodiscard]] auto current() const -> ProcessingLiveConfig;
[[nodiscard]] auto refresh_if_needed() -> ProcessingLiveConfig;
@@ -122,6 +128,7 @@ class ProcessingLiveConfigLoader {
[[nodiscard]] auto read_file_text() const -> std::string;
std::string path_{};
const config::RunConfig& run_config_;
ProcessingLiveConfig current_{};
std::string last_json_text_{};
bool has_last_json_text_ = false;
@@ -1,4 +1,5 @@
#include "data_processor.hpp"
#include "runtime_wait.hpp"
#include <algorithm>
#include <chrono>
@@ -7,7 +8,6 @@
#include <optional>
#include <span>
#include <stdexcept>
#include <thread>
#include <utility>
#include "bscan_processor.hpp"
@@ -27,10 +27,15 @@ constexpr std::size_t kBscanReplayWindow = 50U;
return std::min({preprocessed_capacity, results_capacity, kBscanReplayWindow});
}
void publish_result_collection(const ipc::ResultCollection& collection, ipc::ShmRing& results_ring) {
void publish_result_collection(
const ipc::ResultCollection& collection, ipc::ShmRing& results_ring, std::uint64_t& oversized_results
) {
const auto out_bytes = ipc::serialize_result_collection(collection);
if (!results_ring.push(out_bytes)) {
throw std::runtime_error("Results ring slot is too small for serialized collection");
if (!results_ring.push(out_bytes) && ++oversized_results % 100U == 1U) {
std::cerr << "data_processor: result " << collection.collection_id << " requires " << out_bytes.size()
<< " bytes, results ring slot holds " << results_ring.slot_size_bytes()
<< "; increase rings.results.slot_size_bytes or reduce processing output"
<< " (dropped total=" << oversized_results << ")\n";
}
}
@@ -44,11 +49,12 @@ DataProcessor::DataProcessor(
radar::locator::TcpServer* locator_server
)
: config_(config),
processing_config_(config),
preprocessed_ring_(preprocessed_ring),
results_ring_(results_ring),
processors_(std::move(processors)),
default_processor_mode_(kDefaultProcessorMode),
live_config_loader_(config.runtime.processing_live_config_path),
live_config_loader_(config.runtime.processing_live_config_path, config),
locator_server_(locator_server) {
if (processors_.empty()) {
throw std::runtime_error("DataProcessor requires at least one processor");
@@ -66,6 +72,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::uint64_t last_applied_history_command_seq = 0;
std::uint64_t error_count = 0;
std::uint64_t consecutive_errors = 0;
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
// the current result (gated below by reprocess_current_result).
@@ -82,6 +89,9 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config.gpr_speed_m_s = static_cast<float>(*current_socket_speed);
}
const auto live_revision = live_config_loader_.revision();
if (live_revision != last_replayed_revision) {
processing_config_.gpr = live_config.gpr.value_or(config_.gpr);
}
auto& processor = resolve_processor(live_config);
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
@@ -108,7 +118,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
processor,
live_config
);
publish_result_collection(replay_result, results_ring_);
publish_result_collection(replay_result, results_ring_, oversized_results);
publish_locator(replay_result, live_config);
}
} else if (!preprocessed_history.empty()) {
@@ -118,7 +128,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
processor,
live_config
);
publish_result_collection(replay_result, results_ring_);
publish_result_collection(replay_result, results_ring_, oversized_results);
publish_locator(replay_result, live_config);
}
last_replayed_revision = live_revision;
@@ -156,7 +166,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
processor,
live_config
);
publish_result_collection(result_collection, results_ring_);
publish_result_collection(result_collection, results_ring_, oversized_results);
if (!fed_back) {
publish_locator(result_collection, live_config);
}
@@ -164,14 +174,8 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
continue;
}
std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms));
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
} 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 bad collection (torn ring slot, decode/processing error) must
// not kill the long-running processor: drop it and keep going.
++error_count;
@@ -189,7 +193,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
}
// Back-off (floored at 1ms even if idle_sleep_ms==0) so a persistent error
// cannot busy-spin a core or flood the log.
std::this_thread::sleep_for(std::chrono::milliseconds(std::max<int>(1, config_.runtime.idle_sleep_ms)));
(void)config::wait_or_stop(std::max(1U, config_.runtime.idle_sleep_ms), stop_requested);
}
}
}
@@ -201,11 +205,20 @@ auto DataProcessor::process_collection(
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
const auto started_at = std::chrono::steady_clock::now();
auto result = processor.process_collection(config_, preprocessed, previous_collections, live_config);
auto result = processor.process_collection(processing_config_, preprocessed, previous_collections, live_config);
const auto finished_at = std::chrono::steady_clock::now();
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));
return result;
}
@@ -9,6 +9,7 @@
#include <sstream>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>
#include <nlohmann/json.hpp>
@@ -92,16 +93,55 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
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");
}
const auto number = value.get<double>();
if (!std::isfinite(number) || std::fabs(number) > std::numeric_limits<float>::max()) {
throw std::runtime_error(field_name + " must fit finite float32");
}
return static_cast<float>(number);
}
template <typename T>
void assign_optional(const Json& root, const char* key, T& target) {
const auto found = root.find(key);
if (found == root.end()) {
return;
}
const auto field_name = std::string("processing.") + key;
if constexpr (std::is_same_v<T, float>) {
target = parse_f32(*found, field_name);
} else {
const bool valid = std::is_same_v<T, bool> ? found->is_boolean() : found->is_string();
if (!valid) {
throw std::runtime_error(field_name + " has invalid type");
}
target = found->get<T>();
}
}
[[nodiscard]] auto parse_u64_number(const Json& value, const std::string& field_name) -> std::uint64_t {
if (!value.is_number()) {
throw std::runtime_error(field_name + " must be number");
}
if (value.is_number_unsigned()) {
return value.get<std::uint64_t>();
}
if (value.is_number_integer()) {
const auto integer = value.get<std::int64_t>();
if (integer < 0) {
throw std::runtime_error(field_name + " is out of uint64 range");
}
return static_cast<std::uint64_t>(integer);
}
const double numeric = value.get<double>();
if (numeric < 0.0 || numeric > static_cast<double>(std::numeric_limits<std::uint64_t>::max())) {
if (!std::isfinite(numeric) || numeric < 0.0 || numeric >= static_cast<double>(std::numeric_limits<std::uint64_t>::max())) {
throw std::runtime_error(field_name + " is out of uint64 range");
}
const double rounded = std::round(numeric);
if (std::fabs(numeric - rounded) > 1e-6) {
if (numeric != rounded) {
throw std::runtime_error(field_name + " must be integer");
}
return static_cast<std::uint64_t>(rounded);
@@ -128,7 +168,31 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
return result;
}
[[nodiscard]] auto parse_live_config(const std::string& json_text, const std::string& path) -> ProcessingLiveConfig {
template <typename Entry>
auto parse_geometry(const Json& value, const char* position_field, std::uint32_t Entry::*position_member)
-> std::vector<Entry> {
if (!value.is_array()) {
throw std::runtime_error("processing geometry must be an array");
}
std::vector<Entry> entries{};
entries.reserve(value.size());
for (const auto& record : value) {
if (!record.is_object()) {
throw std::runtime_error("processing geometry entry must be an object");
}
Entry entry{};
entry.*position_member = parse_u32_number(record.at(position_field), position_field);
entry.x_m = parse_f32(record.at("x_m"), "processing.x_m");
assign_optional(record, "y_m", entry.y_m);
assign_optional(record, "z_m", entry.z_m);
entries.push_back(entry);
}
return entries;
}
[[nodiscard]] auto parse_live_config(
const std::string& json_text, const std::string& path, const config::RunConfig& run_config
) -> ProcessingLiveConfig {
if (json_text.empty()) {
throw std::runtime_error("Processing live config is empty: " + path);
}
@@ -145,12 +209,23 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
ProcessingLiveConfig config{};
if (const auto found = root.find("processor_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.processor_mode must be string");
}
config.processor_mode = found->get<std::string>();
if (const auto found = root.find("settings_revision"); found != root.end()) {
config.settings_revision = parse_u64_number(*found, "processing.settings_revision");
}
if (root.contains("relative_permittivity") || root.contains("tx_geometry") || root.contains("rx_geometry")) {
config.gpr = run_config.gpr;
if (const auto found = root.find("relative_permittivity"); found != root.end()) {
config.gpr->relative_permittivity = parse_f32(*found, "processing.relative_permittivity");
}
if (const auto found = root.find("tx_geometry"); found != root.end()) {
config.gpr->tx_geometry = parse_geometry(*found, "output_pos", &config::GprTxGeometry::output_pos);
}
if (const auto found = root.find("rx_geometry"); found != root.end()) {
config.gpr->rx_geometry = parse_geometry(*found, "input_pos", &config::GprRxGeometry::input_pos);
}
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");
@@ -158,66 +233,21 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
config.pass_through_channel =
parse_s_parameter_channel(found->get<std::string>(), "processing.pass_through_channel");
}
if (const auto found = root.find("pass_through_fixed_y_enabled"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.pass_through_fixed_y_enabled must be bool");
}
config.pass_through_fixed_y_enabled = found->get<bool>();
}
if (const auto found = root.find("pass_through_y_min_db"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.pass_through_y_min_db must be number");
}
config.pass_through_y_min_db = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("pass_through_y_max_db"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.pass_through_y_max_db must be number");
}
config.pass_through_y_max_db = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("bscan_axis"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.bscan_axis must be string");
}
config.bscan_axis = found->get<std::string>();
}
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");
}
if (const auto found = root.find("bscan_cut_m"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.bscan_cut_m must be number");
}
config.bscan_cut_m = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("bscan_max_depth_m"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.bscan_max_depth_m must be number");
}
config.bscan_max_depth_m = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("bscan_gain"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.bscan_gain must be number");
}
config.bscan_gain = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("bscan_start_freq_mhz"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.bscan_start_freq_mhz must be number");
}
config.bscan_start_freq_mhz = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("bscan_stop_freq_mhz"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.bscan_stop_freq_mhz must be number");
}
config.bscan_stop_freq_mhz = static_cast<float>(found->get<double>());
}
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);
assign_optional(root, "bscan_start_freq_mhz", config.bscan_start_freq_mhz);
assign_optional(root, "bscan_stop_freq_mhz", config.bscan_stop_freq_mhz);
if (const auto found = root.find("legacy_gpr_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.legacy_gpr_mode must be string");
@@ -237,36 +267,11 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
if (const auto found = root.find("gpr_output_positions"); found != root.end()) {
config.gpr_output_positions = parse_u32_array(*found, "processing.gpr_output_positions");
}
if (const auto found = root.find("gpr_min_depth_m"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_min_depth_m must be number");
}
config.gpr_min_depth_m = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_max_depth_m"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_max_depth_m must be number");
}
config.gpr_max_depth_m = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_range_comp_power"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_range_comp_power must be number");
}
config.gpr_range_comp_power = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_object_min_frac"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_object_min_frac must be number");
}
config.gpr_object_min_frac = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_comp_power"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_comp_power must be number");
}
config.gpr_comp_power = static_cast<float>(found->get<double>());
}
assign_optional(root, "gpr_min_depth_m", config.gpr_min_depth_m);
assign_optional(root, "gpr_max_depth_m", config.gpr_max_depth_m);
assign_optional(root, "gpr_range_comp_power", config.gpr_range_comp_power);
assign_optional(root, "gpr_object_min_frac", config.gpr_object_min_frac);
assign_optional(root, "gpr_comp_power", config.gpr_comp_power);
if (const auto found = root.find("gpr_score_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.gpr_score_mode must be string");
@@ -279,30 +284,10 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
config.gpr_motion_mode = parse_gpr_motion_mode(found->get<std::string>(), "processing.gpr_motion_mode");
}
if (const auto found = root.find("gpr_speed_m_s"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_speed_m_s must be number");
}
config.gpr_speed_m_s = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_look_angle_deg"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_look_angle_deg must be number");
}
config.gpr_look_angle_deg = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_direction_sign"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_direction_sign must be number");
}
config.gpr_direction_sign = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_apply_freq_phase_correction"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.gpr_apply_freq_phase_correction must be bool");
}
config.gpr_apply_freq_phase_correction = found->get<bool>();
}
assign_optional(root, "gpr_speed_m_s", config.gpr_speed_m_s);
assign_optional(root, "gpr_look_angle_deg", config.gpr_look_angle_deg);
assign_optional(root, "gpr_direction_sign", config.gpr_direction_sign);
assign_optional(root, "gpr_apply_freq_phase_correction", config.gpr_apply_freq_phase_correction);
if (const auto found = root.find("gpr_reference_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.gpr_reference_mode must be string");
@@ -310,70 +295,25 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
config.gpr_reference_mode =
parse_legacy_gpr_reference_mode(found->get<std::string>(), "processing.gpr_reference_mode");
}
if (const auto found = root.find("gpr_snr_thresh"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_snr_thresh must be number");
}
config.gpr_snr_thresh = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_snr_comp_max"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_snr_comp_max must be number");
}
config.gpr_snr_comp_max = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_start_freq_mhz"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_start_freq_mhz must be number");
}
config.gpr_start_freq_mhz = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_stop_freq_mhz"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_stop_freq_mhz must be number");
}
config.gpr_stop_freq_mhz = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_background_subtract_enabled"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.gpr_background_subtract_enabled must be bool");
}
config.gpr_background_subtract_enabled = found->get<bool>();
}
assign_optional(root, "gpr_snr_thresh", config.gpr_snr_thresh);
assign_optional(root, "gpr_snr_comp_max", config.gpr_snr_comp_max);
assign_optional(root, "gpr_start_freq_mhz", config.gpr_start_freq_mhz);
assign_optional(root, "gpr_stop_freq_mhz", config.gpr_stop_freq_mhz);
assign_optional(root, "gpr_background_subtract_enabled", config.gpr_background_subtract_enabled);
if (const auto found = root.find("gpr_background_mean_count"); found != root.end()) {
config.gpr_background_mean_count = parse_u32_number(*found, "processing.gpr_background_mean_count");
}
if (const auto found = root.find("gpr_remove_sidelobe_objects_enabled"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.gpr_remove_sidelobe_objects_enabled must be bool");
}
config.gpr_remove_sidelobe_objects_enabled = found->get<bool>();
}
if (const auto found = root.find("gpr_imaging_plane_y_m"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_imaging_plane_y_m must be number");
}
config.gpr_imaging_plane_y_m = static_cast<float>(found->get<double>());
}
assign_optional(root, "gpr_remove_sidelobe_objects_enabled", config.gpr_remove_sidelobe_objects_enabled);
assign_optional(root, "gpr_imaging_plane_y_m", config.gpr_imaging_plane_y_m);
for (const auto& [key, target] : {
std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m},
std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m},
std::pair{"gpr_visible_z_min_m", &config.gpr_visible_z_min_m},
std::pair{"gpr_visible_z_max_m", &config.gpr_visible_z_max_m},
}) {
if (const auto found = root.find(key); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error(std::string("processing.") + key + " must be number");
}
*target = static_cast<float>(found->get<double>());
}
}
if (const auto found = root.find("legacy_gpr_min_visible_pair_count"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.legacy_gpr_min_visible_pair_count must be number");
}
config.legacy_gpr_min_visible_pair_count = static_cast<float>(found->get<double>());
assign_optional(root, key, *target);
}
assign_optional(root, "legacy_gpr_min_visible_pair_count", config.legacy_gpr_min_visible_pair_count);
if (const auto found = root.find("gpr_max_detected_objects_to_draw"); found != root.end()) {
config.gpr_max_detected_objects_to_draw =
parse_u32_number(*found, "processing.gpr_max_detected_objects_to_draw");
@@ -394,36 +334,16 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
std::pair{"sar3d_bp_ground_depth_m", &config.sar3d_bp_ground_depth_m},
std::pair{"sar3d_comp_range_power", &config.sar3d_comp_range_power},
}) {
if (const auto found = root.find(key); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error(std::string("processing.") + key + " must be number");
}
*target = static_cast<float>(found->get<double>());
}
assign_optional(root, key, *target);
}
for (const auto& [key, target] : {
std::pair{"sar3d_use_compensation", &config.sar3d_use_compensation},
std::pair{"sar3d_normalize_each_frame", &config.sar3d_normalize_each_frame},
}) {
if (const auto found = root.find(key); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error(std::string("processing.") + key + " must be bool");
}
*target = found->get<bool>();
}
}
if (const auto found = root.find("ignore_socket_speed"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.ignore_socket_speed must be bool");
}
config.ignore_socket_speed = found->get<bool>();
}
if (const auto found = root.find("reprocess_current_result"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.reprocess_current_result must be bool");
}
config.reprocess_current_result = found->get<bool>();
assign_optional(root, key, *target);
}
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");
}
@@ -438,8 +358,9 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
} // namespace
ProcessingLiveConfigLoader::ProcessingLiveConfigLoader(std::string path)
ProcessingLiveConfigLoader::ProcessingLiveConfigLoader(std::string path, const config::RunConfig& run_config)
: path_(std::move(path)),
run_config_(run_config),
next_check_at_(std::chrono::steady_clock::now()) {
if (path_.empty()) {
throw std::runtime_error("Processing live config path must not be empty");
@@ -472,7 +393,7 @@ auto ProcessingLiveConfigLoader::refresh_if_needed() -> ProcessingLiveConfig {
return current_;
}
current_ = parse_live_config(json_text, path_);
current_ = parse_live_config(json_text, path_, run_config_);
last_json_text_ = json_text;
has_last_json_text_ = true;
++revision_;
@@ -88,6 +88,7 @@ class ClientSession {
private:
void writer_loop();
void reader_loop(std::atomic<double>& shared_vlc_slot, std::atomic<std::int64_t>& shared_vlc_at_ns);
void finish_loop();
int socket_fd_;
std::string peer_name_;
@@ -148,11 +149,7 @@ class TcpServer {
void acceptor_loop();
void enroll_client(std::unique_ptr<ClientSession> session);
void broadcast_packet(const std::vector<std::uint8_t>& packet);
void reap_finished_clients();
// Best-effort reap from the publish path: acquires clients_mutex_ via try-lock
// (skips if contended) and joins exited sessions outside the lock. Keeps
// clients_ bounded even when no new connections arrive to trigger a reap.
void try_reap_finished_clients();
void reap_finished_clients(bool wait_for_lock = true);
void cache_latest_packet(std::vector<std::uint8_t> packet);
[[nodiscard]] auto latest_packet_copy() const -> std::optional<std::vector<std::uint8_t>>;
@@ -6,7 +6,9 @@
#include <cstring>
#include <ctime>
#include <iomanip>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <string>
#include <nlohmann/json.hpp>
@@ -96,17 +98,6 @@ constexpr std::uint32_t kMinTableColumns = 3U; // [x_m, z_m, score, ...]
return stream.str();
}
// Wall-clock epoch milliseconds at packet-build time. Unlike the human-readable
// `tim` field (which has no date and rolls over at midnight), this is a
// monotonically increasing generation stamp consumers can use to compute the
// true age of a snapshot and detect a stalled pipeline.
[[nodiscard]] auto epoch_millis_now() -> std::int64_t {
using Clock = std::chrono::system_clock;
return std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()
).count();
}
void append_u32_little_endian(std::vector<std::uint8_t>& buffer, std::uint32_t value) {
buffer.push_back(static_cast<std::uint8_t>(value & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 8U) & 0xFFU));
@@ -200,15 +191,6 @@ auto build_payload_json(
{"obs", std::move(obs_array)},
};
// `gen` is a build-time generation stamp (epoch ms) that lets a consumer compute
// a packet's true age and flag a cached/stalled snapshot — something `tim` cannot
// express, having no date and rolling over at midnight. It is currently NOT sent;
// set this flag to true to re-enable it (epoch_millis_now() is kept ready for that).
constexpr bool kEmitGenerationStamp = false;
if (kEmitGenerationStamp) {
root["gen"] = epoch_millis_now();
}
return root.dump();
}
@@ -216,6 +198,9 @@ auto encode_packet(
const std::string& payload_json,
std::uint32_t device_id
) -> std::vector<std::uint8_t> {
if (payload_json.size() > std::numeric_limits<std::uint32_t>::max()) {
throw std::runtime_error("Locator packet payload exceeds uint32 wire limit");
}
const auto payload_size = static_cast<std::uint32_t>(payload_json.size());
std::vector<std::uint8_t> packet;
packet.reserve(static_cast<std::size_t>(8U) + payload_json.size());
@@ -225,10 +225,16 @@ void ClientSession::start(
std::atomic<double>& shared_vlc_slot,
std::atomic<std::int64_t>& shared_vlc_at_ns
) {
writer_thread_ = std::thread([this]() { writer_loop(); });
reader_thread_ = std::thread([this, &shared_vlc_slot, &shared_vlc_at_ns]() {
reader_loop(shared_vlc_slot, shared_vlc_at_ns);
});
try {
writer_thread_ = std::thread([this]() { writer_loop(); });
reader_thread_ = std::thread([this, &shared_vlc_slot, &shared_vlc_at_ns]() {
reader_loop(shared_vlc_slot, shared_vlc_at_ns);
});
} catch (...) {
request_stop();
join();
throw;
}
}
void ClientSession::enqueue(std::vector<std::uint8_t> packet) {
@@ -278,14 +284,7 @@ void ClientSession::writer_loop() {
break;
}
}
request_stop();
// Mark exited only once BOTH loops have finished; otherwise a writer-only
// death (e.g. a send timeout while the reader still blocks on recv) would
// never be reaped. request_stop() above shuts the socket so the reader's
// recv() returns promptly.
if (loops_finished_.fetch_add(1, std::memory_order_acq_rel) + 1U == 2U) {
exited_.store(true, std::memory_order_release);
}
finish_loop();
}
void ClientSession::reader_loop(
@@ -295,51 +294,58 @@ void ClientSession::reader_loop(
std::array<std::uint8_t, kPacketHeaderSize> header_buffer{};
std::vector<std::uint8_t> payload_buffer;
while (!stop_requested_.load(std::memory_order_acquire)) {
if (!read_exact(socket_fd_, header_buffer.data(), header_buffer.size(), stop_requested_)) {
break;
}
const auto payload_len = decode_u32_little_endian(header_buffer.data() + 4U);
if (payload_len > max_payload_bytes_) {
log_warning(
"closing client " + peer_name_ + " after payload size "
+ std::to_string(payload_len) + " exceeded the configured limit"
);
break;
}
try {
while (!stop_requested_.load(std::memory_order_acquire)) {
if (!read_exact(socket_fd_, header_buffer.data(), header_buffer.size(), stop_requested_)) {
break;
}
const auto payload_len = decode_u32_little_endian(header_buffer.data() + 4U);
if (payload_len > max_payload_bytes_) {
log_warning(
"closing client " + peer_name_ + " after payload size "
+ std::to_string(payload_len) + " exceeded the configured limit"
);
break;
}
payload_buffer.assign(payload_len, std::uint8_t{0});
if (payload_len > 0U
&& !read_exact(socket_fd_, payload_buffer.data(), payload_len, stop_requested_)) {
break;
}
payload_buffer.assign(payload_len, std::uint8_t{0});
if (payload_len > 0U
&& !read_exact(socket_fd_, payload_buffer.data(), payload_len, stop_requested_)) {
break;
}
try {
const auto json = Json::parse(payload_buffer.begin(), payload_buffer.end());
if (json.is_object()) {
const auto found = json.find("vlc");
if (found != json.end() && found->is_number()) {
const double value = found->get<double>();
if (std::isfinite(value)) {
// Stamp the value first, then the time, so a reader that
// observes a fresh timestamp also observes the matching value.
shared_vlc_slot.store(value, std::memory_order_release);
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()
).count();
shared_vlc_at_ns.store(now_ns, std::memory_order_release);
try {
const auto json = Json::parse(payload_buffer.begin(), payload_buffer.end());
if (json.is_object()) {
const auto found = json.find("vlc");
if (found != json.end() && found->is_number()) {
const double value = found->get<double>();
if (std::isfinite(value) && std::fabs(value) <= std::numeric_limits<float>::max()) {
// Stamp the value first, then the time, so a reader that
// observes a fresh timestamp also observes the matching value.
shared_vlc_slot.store(value, std::memory_order_release);
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()
).count();
shared_vlc_at_ns.store(now_ns, std::memory_order_release);
}
}
}
} catch (const Json::exception& error) {
log_warning(
"ignoring malformed packet from " + peer_name_ + ": " + error.what()
);
}
} catch (const Json::parse_error& error) {
log_warning(
"ignoring malformed packet from " + peer_name_ + ": " + error.what()
);
}
} catch (const std::exception& error) {
log_warning("closing client " + peer_name_ + ": " + error.what());
}
finish_loop();
}
void ClientSession::finish_loop() {
request_stop();
// Mark exited only once BOTH loops have finished (see writer_loop), so a
// reader-only death still waits for the writer before this session is reaped.
// A session is reapable after both threads have stopped using its socket.
if (loops_finished_.fetch_add(1, std::memory_order_acq_rel) + 1U == 2U) {
exited_.store(true, std::memory_order_release);
}
@@ -417,7 +423,13 @@ void TcpServer::start() {
}
listen_fd_ = fd;
acceptor_thread_ = std::thread([this]() { acceptor_loop(); });
try {
acceptor_thread_ = std::thread([this]() { acceptor_loop(); });
} catch (...) {
shutdown_and_close(listen_fd_);
running_.store(false, std::memory_order_release);
throw;
}
}
void TcpServer::stop() {
@@ -427,13 +439,14 @@ void TcpServer::stop() {
if (listen_fd_ >= 0) {
(void)::shutdown(listen_fd_, SHUT_RDWR);
(void)::close(listen_fd_);
listen_fd_ = -1;
}
if (acceptor_thread_.joinable()) {
acceptor_thread_.join();
}
// Keep the descriptor stable until accept() has returned: closing it earlier
// permits fd reuse and races the acceptor's read of listen_fd_.
shutdown_and_close(listen_fd_);
std::vector<std::unique_ptr<ClientSession>> sessions;
{
@@ -466,7 +479,7 @@ void TcpServer::publish(const ipc::ResultCollection& collection, const FilterPar
// Reap exited sessions on the publish path too, so clients_ stays bounded
// even when no new connections arrive to trigger an accept-time reap. Uses a
// try-lock and joins outside the mutex, so publish() never blocks on it.
try_reap_finished_clients();
reap_finished_clients(false);
}
auto TcpServer::latest_socket_speed() const -> std::optional<double> {
@@ -525,19 +538,27 @@ void TcpServer::acceptor_loop() {
reap_finished_clients();
apply_socket_keepalive(client_fd);
auto session = std::make_unique<ClientSession>(
client_fd,
format_peer(peer_addr),
config_.client_queue_size,
config_.max_payload_bytes
);
const auto snapshot = latest_packet_copy();
if (snapshot.has_value()) {
session->enqueue(*snapshot);
std::unique_ptr<ClientSession> session;
bool socket_owned = false;
try {
apply_socket_keepalive(client_fd);
session = std::make_unique<ClientSession>(
client_fd, format_peer(peer_addr), config_.client_queue_size,
config_.max_payload_bytes
);
socket_owned = true;
const auto snapshot = latest_packet_copy();
if (snapshot.has_value()) {
session->enqueue(*snapshot);
}
session->start(latest_socket_speed_, latest_socket_speed_at_ns_);
enroll_client(std::move(session));
} catch (const std::exception& error) {
if (!socket_owned) {
::close(client_fd);
}
log_warning(std::string("cannot start client: ") + error.what());
}
session->start(latest_socket_speed_, latest_socket_speed_at_ns_);
enroll_client(std::move(session));
}
}
@@ -553,35 +574,13 @@ void TcpServer::broadcast_packet(const std::vector<std::uint8_t>& packet) {
}
}
void TcpServer::reap_finished_clients() {
void TcpServer::reap_finished_clients(bool wait_for_lock) {
std::vector<std::unique_ptr<ClientSession>> to_join;
{
std::lock_guard<std::mutex> guard(clients_mutex_);
auto first_dead = std::partition(
clients_.begin(),
clients_.end(),
[](const std::unique_ptr<ClientSession>& session) {
return !session->has_exited();
}
);
for (auto it = first_dead; it != clients_.end(); ++it) {
to_join.push_back(std::move(*it));
}
clients_.erase(first_dead, clients_.end());
}
for (auto& session : to_join) {
session->request_stop();
session->join();
}
}
void TcpServer::try_reap_finished_clients() {
std::vector<std::unique_ptr<ClientSession>> to_join;
{
// try_to_lock: if the acceptor already holds the mutex (it reaps too),
// skip this round rather than block the publish path.
std::unique_lock<std::mutex> guard(clients_mutex_, std::try_to_lock);
if (!guard.owns_lock()) {
std::unique_lock<std::mutex> guard(clients_mutex_, std::defer_lock);
if (wait_for_lock) {
guard.lock();
} else if (!guard.try_lock()) {
return;
}
auto first_dead = std::partition(
@@ -20,10 +20,10 @@
// 5. Optional extended-mode region detection for diffuse reflectors.
//
// Sweep-event model — radar topology matters:
// * Matrix radars (`librevna_multi`, `sn9000`) fire one Tx at a time and
// * Matrix radars (`librevna_multi`) fire one Tx at a time and
// receive on all Rx channels in parallel. A run of 8 traces is just 2
// Tx-events; all (tx_k, *) pairs share one timestamp.
// * Sequential radars (single librevna with switches, kamil_adc, k209…)
// * Sequential radars (single librevna with switches, kamil_adc)
// measure each pair separately. A run of 8 traces is 8 separate sweeps;
// every pair has its own timestamp.
// Both cases reduce to: there are N events in the frame, each event lasts
@@ -283,7 +283,7 @@ enum class LegacyPeakDomain {
}
[[nodiscard]] auto is_matrix_radar_model(const std::string& model) -> bool {
return model == "librevna_multi" || model == "sn9000";
return model == "librevna_multi";
}
// Assign an `event_index` to every selected pair. The mapping depends on the
@@ -1,19 +1,76 @@
#include "sar3d_gpu.hpp"
#include <algorithm>
#include <bit>
#include <climits>
#include <cmath>
#include <limits>
#include <stdexcept>
#include <string>
#ifdef SAR3D_WITH_CUDA
#include <cuda.h>
#include <nvrtc.h>
#include <algorithm>
#include <climits>
#include <cmath>
#include <string>
#include <type_traits>
#endif
namespace radar::processing::sar3d {
namespace {
constexpr std::size_t kMaxIndexedItems = static_cast<std::size_t>(INT_MAX);
auto checked_product(std::size_t a, std::size_t b, std::size_t limit, const char* what) -> std::size_t {
if (a == 0U || b == 0U || a > limit / b) {
throw std::runtime_error(std::string("sar3d ") + what + " is too large or empty");
}
return a * b;
}
#ifdef SAR3D_WITH_CUDA
auto gaussian_taps(double sigma) -> std::vector<float> {
if (!(sigma > 0.0) || !std::isfinite(sigma) || sigma > 100.0) {
throw std::runtime_error("sar3d smoothing sigma must be between 0 and 100");
}
const int radius = static_cast<int>(4.0 * sigma + 0.5);
std::vector<double> weights;
double total = 0.0;
for (int k = -radius; k <= radius; ++k) {
weights.push_back(std::exp(-0.5 * k * k / (sigma * sigma)));
total += weights.back();
}
std::vector<float> taps;
for (const double weight : weights) {
taps.push_back(static_cast<float>(weight / total));
}
return taps;
}
auto reference_plane(const VolumeRequest& request, std::size_t iy) -> bool {
const double margin = request.norm_edge_margin_m;
if (request.y.size() == 2U || request.y.back() - request.y.front() <= 2.5 * margin) {
return true;
}
return request.y[iy] >= request.y.front() + margin && request.y[iy] <= request.y.back() - margin;
}
void choose_peak(Projections& result, const VolumeRequest& request,
std::span<const float> levels, std::span<const unsigned int> indices = {}) {
for (std::size_t iy = 0U; iy < request.y.size(); ++iy) {
if (!std::isfinite(levels[iy])) {
throw std::runtime_error("sar3d volume contains a non-finite value");
}
if (reference_plane(request, iy) && levels[iy] > result.level) {
result.level = levels[iy];
if (!indices.empty()) {
result.peak = {iy, indices[iy] / request.x.size(), indices[iy] % request.x.size()};
}
}
}
}
#endif
} // namespace
#ifdef SAR3D_WITH_CUDA
namespace {
@@ -39,8 +96,9 @@ extern "C" __global__ void inverse_dft(
const float2* __restrict__ bands, const int points, const int first_bin,
const int size, const int items, float2* __restrict__ ascans)
{
const int item = blockIdx.x * blockDim.x + threadIdx.x;
if (item >= items) return;
const unsigned index = blockIdx.x * blockDim.x + threadIdx.x;
if (index >= (unsigned)items) return;
const int item = (int)index;
const unsigned k = (unsigned)(item % size);
const unsigned mask = (unsigned)size - 1u;
const float2* band = bands + (item / size) * points;
@@ -72,8 +130,9 @@ extern "C" __global__ void backproject(
const float comp_power, const float comp_max,
float* __restrict__ volume)
{
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
if (voxel >= voxels) return;
const unsigned index = blockIdx.x * blockDim.x + threadIdx.x;
if (index >= (unsigned)voxels) return;
const int voxel = (int)index;
const int row = voxel / nx;
// Scene -> radar axes (sar_scene_to_radar_yz). The depth gate is decided in
// double so voxels on its edge are classified like in the reference.
@@ -87,7 +146,7 @@ extern "C" __global__ void backproject(
const float px = (float)gx[voxel - row * nx];
const float py = (float)(y0 * cos_p + zs * sin_p);
const float pz = (float)depth;
const float last = (float)(samples - 1);
const double last = (double)(samples - 1);
float re = 0.0f, im = 0.0f, weights = 0.0f;
for (int c = 0; c < count; ++c) {
const Contribution k = contributions[c];
@@ -96,7 +155,7 @@ extern "C" __global__ void backproject(
const float rt = sqrtf(dxt * dxt + dyt * dyt + dzt * dzt);
const float rr = sqrtf(dxr * dxr + dyr * dyr + dzr * dzr);
const float position = (rt + rr) * samples_per_metre;
if (position > last) continue;
if (!isfinite(position) || position < 0.0f || (double)position > last) continue;
const int i = min((int)position, samples - 2);
const float frac = position - (float)i;
const float2* row_ascan = ascans + (long long)c * samples;
@@ -111,7 +170,7 @@ extern "C" __global__ void backproject(
im += w * (a.y + frac * (b.y - a.y));
weights += k.weight;
}
volume[voxel] = sqrtf(re * re + im * im) / (weights + 1e-12f);
volume[voxel] = hypotf(re, im) / (weights + 1e-12f);
}
// One pass of scipy.ndimage.gaussian_filter1d (mode 'reflect') along Z or X.
@@ -120,8 +179,9 @@ extern "C" __global__ void smooth(
const float* __restrict__ taps, const int radius,
const int nx, const int nz, const int voxels, const int along_z)
{
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
if (voxel >= voxels) return;
const unsigned thread = blockIdx.x * blockDim.x + threadIdx.x;
if (thread >= (unsigned)voxels) return;
const int voxel = (int)thread;
const int length = along_z ? nz : nx;
const int index = along_z ? (voxel / nx) % nz : voxel % nx;
const int stride = along_z ? nx : 1;
@@ -134,17 +194,36 @@ extern "C" __global__ void smooth(
dst[voxel] = sum;
}
// dst[o] = max over r of src[(o / inner) * outer_stride + (o % inner) * inner_stride + r * reduce_stride].
// Strides select the displayed axes and the axis reduced to its maximum.
extern "C" __global__ void max_project(
const float* __restrict__ src, float* __restrict__ dst, const int outputs, const int inner,
const int outer_stride, const int inner_stride, const int reduce, const int reduce_stride)
{
const int o = blockIdx.x * blockDim.x + threadIdx.x;
if (o >= outputs) return;
const float* p = src + (o / inner) * outer_stride + (o % inner) * inner_stride;
float m = 0.0f;
for (int r = 0; r < reduce; ++r) m = fmaxf(m, p[r * reduce_stride]);
dst[o] = m;
const unsigned thread = blockIdx.x * blockDim.x + threadIdx.x;
if (thread >= (unsigned)outputs) return;
const int o = (int)thread;
const float* row = src + (o / inner) * outer_stride + (o % inner) * inner_stride;
float maximum = 0.0f;
for (int index = 0; index < reduce; ++index) maximum = fmaxf(maximum, row[index * reduce_stride]);
dst[o] = maximum;
}
// Keep the first joint (y,z,x) maximum. Separate projections cannot locate it.
extern "C" __global__ void peak_planes(
const float* volume, const int plane, const int ny, float* levels, unsigned int* indices)
{
const unsigned thread = blockIdx.x * blockDim.x + threadIdx.x;
if (thread >= (unsigned)ny) return;
const int y = (int)thread;
const float* row = volume + y * plane;
float level = 0.0f;
unsigned int index = 0u;
for (int i = 0; i < plane; ++i) {
if (!isfinite(row[i])) { levels[y] = row[i]; indices[y] = (unsigned int)i; return; }
if (row[i] > level) { level = row[i]; index = (unsigned int)i; }
}
levels[y] = level;
indices[y] = index;
}
// Orthographic max projection. The volume is non-negative, so float bit
@@ -158,8 +237,9 @@ extern "C" __global__ void project_oblique(
const double u0, const double v0, const double inv_pixel, const int nu, const int nv,
unsigned int* __restrict__ image)
{
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
if (voxel >= voxels) return;
const unsigned index = blockIdx.x * blockDim.x + threadIdx.x;
if (index >= (unsigned)voxels) return;
const int voxel = (int)index;
const float value = volume[voxel];
if (!(value > 0.0f)) return;
const int row = voxel / nx;
@@ -175,11 +255,13 @@ extern "C" __global__ void project_oblique(
constexpr int kBlockSize = 256;
void check(CUresult result, const char* what) {
void check(CUresult result, const char* what, bool device_required = false) {
if (result != CUDA_SUCCESS) {
const char* text = nullptr;
cuGetErrorString(result, &text);
throw std::runtime_error(std::string{"CUDA "} + what + ": " + (text != nullptr ? text : "unknown error"));
const auto message = std::string{"CUDA "} + what + ": " + (text != nullptr ? text : "unknown error");
if (device_required) { throw CudaUnavailable(message); }
throw std::runtime_error(message);
}
}
@@ -299,20 +381,20 @@ template <class T>
template <class... Args>
void launch(CUfunction kernel, int items, Args... args) {
void* parameters[] = {static_cast<void*>(&args)...};
const auto blocks = static_cast<unsigned>((items + kBlockSize - 1) / kBlockSize);
const auto blocks = (static_cast<unsigned>(items) + kBlockSize - 1U) / kBlockSize;
check(cuLaunchKernel(kernel, blocks, 1U, 1U, kBlockSize, 1U, 1U, 0U, nullptr, parameters, nullptr), "launch");
}
} // namespace
struct Gpu::Impl {
struct CudaBackend {
// Declared first, destroyed last: buffers and the module need the context.
struct Context {
CUdevice device = 0;
CUcontext handle = nullptr;
Context() {
check(cuInit(0U), "init");
check(cuDeviceGet(&device, 0), "device");
check(cuInit(0U), "init", true);
check(cuDeviceGet(&device, 0), "device", true);
check(cuDevicePrimaryCtxRetain(&handle, device), "context");
if (const CUresult result = cuCtxSetCurrent(handle); result != CUDA_SUCCESS) {
cuDevicePrimaryCtxRelease(device);
@@ -331,10 +413,11 @@ struct Gpu::Impl {
return kernel;
}
} module{compile_kernels(context.device)};
CUfunction inverse_dft = module.function("inverse_dft");
CUfunction inverse_dft_kernel = module.function("inverse_dft");
CUfunction backproject = module.function("backproject");
CUfunction smooth = module.function("smooth");
CUfunction max_project = module.function("max_project");
CUfunction peak_planes = module.function("peak_planes");
CUfunction project_oblique = module.function("project_oblique");
int smooth_radius = 0;
DeviceBuffer taps;
@@ -345,50 +428,36 @@ struct Gpu::Impl {
DeviceBuffer volume;
DeviceBuffer scratch;
DeviceBuffer images;
DeviceBuffer peak_stats;
~Impl() { cuCtxSetCurrent(context.handle); }
~CudaBackend() { cuCtxSetCurrent(context.handle); }
explicit CudaBackend(std::span<const float> weights) {
smooth_radius = static_cast<int>(weights.size() / 2U);
taps.upload(weights);
}
auto inverse_dft(std::span<const std::complex<float>>, std::size_t, std::size_t, std::size_t) -> std::vector<std::complex<float>>;
auto project(const VolumeRequest&) -> Projections;
};
Gpu::Gpu(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
// scipy.ndimage.gaussian_filter1d taps: radius int(4 sigma + 0.5), normalized.
const int radius = static_cast<int>(4.0 * smooth_sigma_voxels + 0.5);
std::vector<double> weights{};
for (int k = -radius; k <= radius; ++k) {
weights.push_back(std::exp(-0.5 * k * k / (smooth_sigma_voxels * smooth_sigma_voxels)));
}
double total = 0.0;
for (const double weight : weights) {
total += weight;
}
std::vector<float> taps{};
for (const double weight : weights) {
taps.push_back(static_cast<float>(weight / total));
}
impl_->smooth_radius = radius;
impl_->taps.upload(std::span<const float>(taps));
}
Gpu::~Gpu() = default;
auto Gpu::inverse_dft(
auto CudaBackend::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>> {
Impl& gpu = *impl_;
CudaBackend& 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");
const CUdeviceptr input = gpu.bands.upload(bands);
const CUdeviceptr output = gpu.ascans.reserve(sizeof(std::complex<float>) * samples);
launch(gpu.inverse_dft, items, input, checked_int(points, "band"), checked_int(first_bin, "first bin"),
launch(gpu.inverse_dft_kernel, items, input, checked_int(points, "band"), checked_int(first_bin, "first bin"),
checked_int(size, "A-scan"), items, output);
return download<std::complex<float>>(output, samples);
}
auto Gpu::project(const VolumeRequest& request) -> Projections {
Impl& gpu = *impl_;
auto CudaBackend::project(const VolumeRequest& request) -> Projections {
CudaBackend& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const int nx = checked_int(request.x.size(), "grid");
@@ -413,6 +482,12 @@ auto Gpu::project(const VolumeRequest& request) -> Projections {
std::cos(request.pitch_rad), std::sin(request.pitch_rad), request.ground_depth_m,
request.min_depth_m, request.max_depth_m, static_cast<float>(request.samples_per_metre),
static_cast<int>(request.use_compensation), request.comp_range_power, request.comp_weight_max, volume);
const int plane = nz * nx;
const auto stats = gpu.peak_stats.reserve(2U * sizeof(float) * static_cast<std::size_t>(ny));
launch(gpu.peak_planes, ny, volume, plane, ny, stats, stats + sizeof(float) * static_cast<std::size_t>(ny));
Projections raw_peak;
const auto raw_levels = download<float>(stats, static_cast<std::size_t>(ny));
choose_peak(raw_peak, request, raw_levels);
// Separable Gaussian with sigma (0, s, s) over [y][z][x]: Z first, then X, like scipy.
const CUdeviceptr taps = gpu.taps.pointer();
launch(gpu.smooth, voxels, volume, scratch, taps, gpu.smooth_radius, nx, nz, voxels, 1);
@@ -423,49 +498,112 @@ auto Gpu::project(const VolumeRequest& request) -> Projections {
const int side_size = ny * nz;
const int top_size = nz * nx;
const int oblique_size = checked_int(static_cast<std::size_t>(view.nv) * static_cast<std::size_t>(view.nu), "view");
const auto image_floats = static_cast<std::size_t>(front_size + side_size + top_size + oblique_size);
const auto image_floats = static_cast<std::size_t>(front_size) + side_size + top_size + oblique_size + 2U * ny;
const CUdeviceptr front = gpu.images.reserve(sizeof(float) * image_floats);
const CUdeviceptr side = front + sizeof(float) * static_cast<std::size_t>(front_size);
const CUdeviceptr top = side + sizeof(float) * static_cast<std::size_t>(side_size);
const CUdeviceptr oblique = top + sizeof(float) * static_cast<std::size_t>(top_size);
const int plane = nz * nx;
const CUdeviceptr levels = oblique + sizeof(float) * static_cast<std::size_t>(oblique_size);
const CUdeviceptr indices = levels + sizeof(float) * static_cast<std::size_t>(ny);
launch(gpu.peak_planes, ny, volume, plane, ny, levels, indices);
Projections result;
result.raw_level = raw_peak.level;
const auto plane_levels = download<float>(levels, static_cast<std::size_t>(ny));
const auto plane_indices = download<unsigned int>(indices, static_cast<std::size_t>(ny));
choose_peak(result, request, plane_levels, plane_indices);
launch(gpu.max_project, front_size, volume, front, front_size, nx, plane, 1, nz, nx);
launch(gpu.max_project, side_size, volume, side, side_size, nz, plane, nx, nx, 1);
launch(gpu.max_project, top_size, volume, top, top_size, nx, nx, 1, ny, plane);
check(cuMemsetD32(oblique, 0U, static_cast<std::size_t>(oblique_size)), "clear");
launch(gpu.project_oblique, voxels, volume, gx, gy, gz, 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.0 / view.pixel_m, view.nu, view.nv, oblique);
return Projections{
.front = download<float>(front, static_cast<std::size_t>(front_size)),
.side = download<float>(side, static_cast<std::size_t>(side_size)),
.top = download<float>(top, static_cast<std::size_t>(top_size)),
.oblique = download<float>(oblique, static_cast<std::size_t>(oblique_size)),
};
}
#else // !SAR3D_WITH_CUDA
struct Gpu::Impl {};
Gpu::Gpu(double /*smooth_sigma_voxels*/) {
throw std::runtime_error(
"data_processor was built without CUDA (nvrtc.h, cuda.h, libnvrtc or libcuda not found), "
"so sar3d cannot run; install the CUDA requirements and rebuild"
);
}
Gpu::~Gpu() = default;
auto Gpu::inverse_dft(std::span<const std::complex<float>>, std::size_t, std::size_t, std::size_t)
-> std::vector<std::complex<float>> {
return {};
}
auto Gpu::project(const VolumeRequest& /*request*/) -> Projections {
return {};
result.front = download<float>(front, static_cast<std::size_t>(front_size));
result.side = download<float>(side, static_cast<std::size_t>(side_size));
result.top = download<float>(top, static_cast<std::size_t>(top_size));
result.oblique = download<float>(oblique, static_cast<std::size_t>(oblique_size));
return result;
}
#endif
struct Engine::Impl {
#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));
#else
(void)smooth_sigma_voxels;
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA; this program was built without "
"nvrtc.h, cuda.h, libnvrtc or libcuda. Install CUDA and rebuild.");
#endif
}
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");
}
(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);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
}
auto Engine::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)");
const auto& view = request.oblique;
if (nx < 2U || ny < 2U || nz < 2U || request.samples_per_ascan < 2U ||
request.contributions.empty() || request.samples_per_metre <= 0.0 ||
!std::isfinite(request.samples_per_metre) || request.samples_per_metre > std::numeric_limits<float>::max() ||
view.nu <= 0 || view.nv <= 0 || !(view.pixel_m > 0.0) || !std::isfinite(view.pixel_m)) {
throw std::runtime_error("sar3d invalid grid, aperture, A-scan or oblique dimensions");
}
const auto scan_count = checked_product(request.contributions.size(), request.samples_per_ascan,
kMaxIndexedItems, "A-scan stack");
if (request.ascans.size() != scan_count) {
throw std::runtime_error("sar3d A-scan stack does not match the aperture");
}
for (const auto axis : {request.x, request.y, request.z}) {
for (const double coordinate : axis) {
if (!std::isfinite(coordinate) || std::abs(coordinate) > std::numeric_limits<float>::max()) {
throw std::runtime_error("sar3d grid contains an unsupported coordinate");
}
}
}
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) {
throw std::runtime_error("sar3d aperture contains an invalid weight or reference range");
}
for (const auto point : {term.tx, term.rx}) {
if (!std::ranges::all_of(point, [](float value) { return std::isfinite(value); })) {
throw std::runtime_error("sar3d aperture contains a non-finite antenna position");
}
}
}
(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);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
}
} // namespace radar::processing::sar3d
@@ -5,13 +5,19 @@
#include <cstddef>
#include <memory>
#include <span>
#include <stdexcept>
#include <vector>
// GPU half of the sar3d processor: A-scans of the band spectra, and the
// Numerical 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 of the volume leave the GPU.
// Only the projections and peak leave the backend.
namespace radar::processing::sar3d {
class CudaUnavailable : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// One (aperture frame, Tx/Rx pair) term of the backprojection sum. The layout
// is shared with the CUDA kernel.
struct Contribution {
@@ -51,25 +57,29 @@ struct VolumeRequest {
bool use_compensation = true;
float comp_range_power = 0.0F;
float comp_weight_max = 0.0F;
double norm_edge_margin_m = 0.0;
ObliqueView oblique{};
};
// Max-projections of the smoothed, not yet normalized volume (row-major).
// Maximum projections of the smoothed volume (row-major), before normalization.
struct Projections {
std::vector<float> front; // [y][x], maximum along Z
std::vector<float> side; // [y][z], maximum along X
std::vector<float> top; // [z][x], maximum along Y
std::vector<float> oblique; // [v][u]
float level = 0.0F;
float raw_level = 0.0F;
std::array<std::size_t, 3> peak{}; // joint voxel maximum, [y, z, x]
};
// CUDA context with the kernels compiled for the installed GPU. Construction
// throws when no usable GPU is present or the processor was built without CUDA.
class Gpu {
// CUDA context and runtime-compiled kernels. A missing device raises
// CudaUnavailable; computation and compilation failures remain ordinary errors.
class Engine {
public:
explicit Gpu(double smooth_sigma_voxels);
~Gpu();
Gpu(const Gpu&) = delete;
auto operator=(const Gpu&) -> Gpu& = delete;
explicit Engine(double smooth_sigma_voxels);
~Engine();
Engine(const Engine&) = delete;
auto operator=(const Engine&) -> Engine& = 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.
@@ -7,11 +7,13 @@
#include <complex>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <iostream>
#include <limits>
#include <map>
#include <memory>
#include <numbers>
#include <set>
#include <span>
#include <stdexcept>
#include <string>
@@ -158,22 +160,26 @@ void select_antennas(
struct TraceClock {
double start_s = 0.0; // sweep start
double event_s = 0.0; // sweep duration / (stages * points)
long long stage = 0;
long long stages = 1;
std::size_t stage = 0U;
std::size_t stages = 1U;
[[nodiscard]] auto sample_time_s(std::size_t point) const -> double {
return start_s + static_cast<double>(stages * static_cast<long long>(point) + stage) * event_s;
return start_s + (static_cast<double>(stages) * point + stage) * event_s;
}
};
struct CollectionTiming {
double center_s = 0.0; // absolute acquisition centre, positions the frame in the aperture
std::uint64_t center_ns = 0U; // integer centre avoids overflow and loss of relative timing
double center_fraction_ns = 0.0;
std::vector<TraceClock> clocks; // per collection trace
};
// Timing from the per-trace capture windows (snapshot_acquisition_timing); every
// trace takes part, so the stages do not depend on the INPUT_IDX/OUTPUT_IDX choice.
[[nodiscard]] auto collection_timing(const ipc::PreprocessedCollection& collection) -> CollectionTiming {
if (collection.traces.empty()) {
throw std::runtime_error("collection has no traces");
}
std::map<std::pair<std::uint64_t, std::uint64_t>, std::vector<std::uint32_t>> sweep_outputs{};
std::uint64_t first = std::numeric_limits<std::uint64_t>::max();
std::uint64_t last = 0U;
@@ -181,6 +187,9 @@ struct CollectionTiming {
if (trace.capture_end_ns <= trace.capture_start_ns) {
throw std::runtime_error("collection has traces without a capture window");
}
if (trace.frequency_hz.empty()) {
throw std::runtime_error("collection has a trace without frequency points");
}
sweep_outputs[{trace.capture_start_ns, trace.capture_end_ns}].push_back(trace.combo.output_pos);
first = std::min(first, trace.capture_start_ns);
last = std::max(last, trace.capture_end_ns);
@@ -191,18 +200,19 @@ struct CollectionTiming {
}
CollectionTiming timing{};
const auto center_x2_ns = static_cast<std::int64_t>(first + last);
timing.center_s = 0.5e-9 * static_cast<double>(center_x2_ns);
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();
TraceClock clock{};
clock.start_s =
0.5e-9 * static_cast<double>(2 * static_cast<std::int64_t>(trace.capture_start_ns) - center_x2_ns);
clock.start_s = 1e-9 * (static_cast<double>(trace.capture_start_ns - first) -
0.5 * static_cast<double>(duration_ns));
clock.stage = stage;
clock.stages = static_cast<long long>(stages.size());
clock.stages = stages.size();
clock.event_s = 1e-9 * static_cast<double>(trace.capture_end_ns - trace.capture_start_ns) /
static_cast<double>(clock.stages * static_cast<long long>(trace.frequency_hz.size()));
static_cast<double>(clock.stages) / trace.frequency_hz.size();
timing.clocks.push_back(clock);
}
return timing;
@@ -226,6 +236,10 @@ struct Band {
}
std::vector<std::size_t> points{};
for (std::size_t point = 0U; point < frequency.size(); ++point) {
if (!std::isfinite(frequency[point]) || frequency[point] < 0.0F ||
(point > 0U && frequency[point] <= frequency[point - 1U])) {
throw std::runtime_error("trace frequencies must be finite, non-negative and increasing");
}
if (frequency[point] >= settings.f_start_hz && frequency[point] <= settings.f_stop_hz) {
points.push_back(point);
}
@@ -242,7 +256,15 @@ struct Band {
if (!(band.df_hz > 0.0)) {
throw std::runtime_error("trace frequencies do not increase");
}
band.first_bin = static_cast<std::size_t>(std::nearbyint(frequency[points.front()] / band.df_hz));
// Bound before converting to an integer or calling bit_ceil. CUDA uses
// 32-bit indexing; pathological sub-Hz steps must not wrap CUDA indices.
constexpr std::size_t max_fft = 1U << 30U; // largest power of two inside CUDA's signed index
const double first_bin = std::nearbyint(frequency[points.front()] / band.df_hz);
if (!std::isfinite(first_bin) || first_bin < 0.0 ||
first_bin + points.size() - 1U > max_fft / (2U * kOversample)) {
throw std::runtime_error("frequency band exceeds the 32-bit CUDA A-scan indexing");
}
band.first_bin = static_cast<std::size_t>(first_bin);
band.size = std::bit_ceil(2U * (band.first_bin + points.size() - 1U)) * kOversample;
// The radar moves SPEED_M_S * cos(LOOK_ANGLE_DEG) along range; its offset
@@ -255,6 +277,9 @@ struct Band {
const std::size_t point = points[index];
const double hann = 0.5 - 0.5 * std::cos(2.0 * pi * static_cast<double>(index) / last);
const std::complex<double> sample(trace.s21[point].re, trace.s21[point].im);
if (!std::isfinite(sample.real()) || !std::isfinite(sample.imag())) {
throw std::runtime_error("S21 contains a non-finite sample");
}
band.spectrum.emplace_back(
sample * std::polar(hann, phase_per_hz_s * frequency[point] * clock.sample_time_s(point))
);
@@ -289,7 +314,8 @@ 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 {
double center_s = 0.0;
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]
@@ -298,7 +324,7 @@ struct Frame {
[[nodiscard]] auto compute_frame(
const ipc::PreprocessedCollection& collection,
const Settings& settings,
sar3d::Gpu& gpu
sar3d::Engine& backend
) -> Frame {
const auto timing = collection_timing(collection);
std::vector<Band> bands{};
@@ -327,10 +353,11 @@ struct Frame {
spectra.insert(spectra.end(), band.spectrum.begin(), band.spectrum.end());
}
Frame frame{};
frame.center_s = timing.center_s;
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 = gpu.inverse_dft(spectra, first.spectrum.size(), first.first_bin, first.size);
frame.ascans = backend.inverse_dft(spectra, first.spectrum.size(), first.first_bin, first.size);
if (settings.normalize_each_frame) {
for (std::size_t offset = 0U; offset < frame.ascans.size(); offset += frame.samples) {
normalize_ascan(std::span(frame.ascans).subspan(offset, frame.samples), frame.dt_s, settings);
@@ -350,9 +377,11 @@ struct Frame {
return axis;
}
// Axis with about the given pitch (sar_build_full_region_grid: round(span/pitch)+1 points).
[[nodiscard]] auto grid_axis(double start, double stop, double pitch) -> std::vector<double> {
return linspace(start, stop, static_cast<std::size_t>(std::max(std::nearbyint((stop - start) / pitch) + 1.0, 2.0)));
[[nodiscard]] auto checked_axis_size(double count) -> std::size_t {
if (!std::isfinite(count) || count < 1.0 || count > std::numeric_limits<int>::max()) {
throw std::runtime_error("SAR axis exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
}
return static_cast<std::size_t>(count);
}
struct Grid {
@@ -367,6 +396,9 @@ struct Grid {
[[nodiscard]] auto scene_grid(const Settings& settings) -> Grid {
const double cos_p = std::cos(settings.look_angle_rad);
const double sin_p = std::sin(settings.look_angle_rad);
if (std::abs(cos_p) < 1e-6) {
throw std::runtime_error("SAR grid is undefined at a radar look angle of 90 degrees");
}
double z_min = std::numeric_limits<double>::infinity();
double z_max = -std::numeric_limits<double>::infinity();
for (const double height : {kSceneYMin, kSceneYMax}) {
@@ -377,11 +409,19 @@ struct Grid {
z_max = std::max(z_max, z);
}
}
return {
grid_axis(kSceneXMin, kSceneXMax, settings.voxel_m),
grid_axis(kSceneYMin, kSceneYMax, settings.voxel_m),
grid_axis(z_min, z_max, settings.voxel_m),
// Match sar_build_full_region_grid and reject oversized products before
// allocating the axes. Every downstream kernel uses signed 32-bit indices.
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 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),
linspace(z_min, z_max, nz)};
}
// Cosine taper over the aperture: 1 at the centre frame, SAR_EDGE_FRAME_WEIGHT at
@@ -463,8 +503,11 @@ struct Oblique {
}
}
}
view.nu = static_cast<int>(std::lround((u_max - view.u0) / view.pixel_m)) + 1;
view.nv = static_cast<int>(std::lround((v_max - view.v0) / view.pixel_m)) + 1;
view.nu = static_cast<int>(checked_axis_size(std::round((u_max - view.u0) / view.pixel_m) + 1.0));
view.nv = static_cast<int>(checked_axis_size(std::round((v_max - view.v0) / view.pixel_m) + 1.0));
if (view.nu > std::numeric_limits<int>::max() / view.nv) {
throw std::runtime_error("SAR oblique image exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
}
for (int index = 0; index < view.nu; ++index) {
oblique.u.push_back(view.u0 + index * view.pixel_m);
}
@@ -479,6 +522,11 @@ struct Oblique {
}
[[nodiscard]] auto to_float(const std::array<double, 3>& point) -> std::array<float, 3> {
for (const double coordinate : point) {
if (!std::isfinite(coordinate) || std::abs(coordinate) > std::numeric_limits<float>::max()) {
throw std::runtime_error("SAR antenna motion exceeds the supported coordinate range");
}
}
return {static_cast<float>(point[0]), static_cast<float>(point[1]), static_cast<float>(point[2])};
}
@@ -511,6 +559,15 @@ struct Oblique {
return payload;
}
[[nodiscard]] auto status_payload(float code, const std::string& message) -> ipc::ResultPayload {
std::vector<float> values{code};
for (const unsigned char byte : message) {
values.push_back(byte);
}
const auto columns = static_cast<std::uint32_t>(values.size());
return table_payload("sar3d_status", columns, std::move(values));
}
} // namespace
struct Sar3dProcessor::State {
@@ -521,44 +578,42 @@ struct Sar3dProcessor::State {
return {collection.collection_id, collection.monotonic_ns};
}
// A-scans by sweep for `frames_key`; null marks a sweep that could not be
// processed, so its error is reported once and not retried.
std::map<SweepKey, std::shared_ptr<const Frame>> frames{};
struct CachedFrame {
std::shared_ptr<const Frame> value;
std::string error;
};
std::map<SweepKey, CachedFrame> frames{};
AscanKey frames_key{};
std::unique_ptr<sar3d::Gpu> gpu{};
std::string gpu_error{};
std::unique_ptr<sar3d::Engine> engine{};
std::exception_ptr engine_error{};
std::string last_error{};
std::uint64_t repeated_errors = 0U;
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Gpu& gpu)
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Engine& backend)
-> std::shared_ptr<const Frame> {
if (const auto found = frames.find(sweep_key(collection)); found != frames.end()) {
return found->second;
if (found->second.value == nullptr) { report_error(found->second.error); }
return found->second.value;
}
std::shared_ptr<const Frame> frame{};
std::string failure;
try {
frame = std::make_shared<const Frame>(compute_frame(collection, settings, gpu));
frame = std::make_shared<const Frame>(compute_frame(collection, settings, backend));
} catch (const std::exception& error) {
report_error(error.what());
failure = error.what();
report_error(failure);
}
frames.emplace(sweep_key(collection), frame);
frames.emplace(sweep_key(collection), CachedFrame{frame, std::move(failure)});
return frame;
}
// Set up once: compiling the kernels takes seconds, so a failure is kept
// instead of being retried on every frame.
auto gpu_engine() -> sar3d::Gpu& {
if (gpu == nullptr && gpu_error.empty()) {
try {
gpu = std::make_unique<sar3d::Gpu>(kSmoothSigma);
} catch (const std::exception& error) {
gpu_error = error.what();
}
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(); }
}
if (gpu == nullptr) {
throw std::runtime_error(gpu_error);
}
return *gpu;
if (engine == nullptr) { std::rethrow_exception(engine_error); }
return *engine;
}
auto render(
@@ -566,23 +621,29 @@ struct Sar3dProcessor::State {
std::span<const ipc::PreprocessedCollection> previous,
const Settings& settings
) -> std::vector<ipc::ResultPayload> {
auto& gpu = gpu_engine();
auto& backend = numerical_engine();
if (settings.ascan_key() != frames_key) {
frames.clear();
frames_key = settings.ascan_key();
}
const auto newest = frame(collection, settings, gpu);
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);
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
// cannot grow the failed-frame cache forever.
std::erase_if(frames, [&](const auto& entry) { return !retained.contains(entry.first); });
const auto newest = frame(collection, settings, backend);
if (newest == nullptr) {
return {};
return {status_payload(2.0F, last_error)};
}
// Aperture: the newest 2r+1 collections, centred r collections before
// the newest one, so the image trails the input by r frames. Collections
// that left the aperture are dropped from the cache.
std::vector<std::shared_ptr<const Frame>> aperture{};
std::map<SweepKey, std::shared_ptr<const Frame>> kept{{sweep_key(collection), newest}};
for (const auto& member : previous.last(std::min(previous.size(), 2U * settings.frame_radius))) {
auto member_frame = frame(member, settings, gpu);
kept.emplace(sweep_key(member), member_frame);
for (const auto& member : recent) {
auto member_frame = frame(member, settings, backend);
const bool same_axis = member_frame != nullptr && member_frame->samples == newest->samples &&
member_frame->dt_s == newest->dt_s;
if (same_axis) {
@@ -590,14 +651,16 @@ struct Sar3dProcessor::State {
}
}
aperture.push_back(newest);
frames = std::move(kept);
const std::size_t centre_index =
aperture.size() > settings.frame_radius ? aperture.size() - 1U - settings.frame_radius : 0U;
const Frame& centre = *aperture[centre_index];
std::vector<double> offsets_s{};
for (const auto& member : aperture) {
offsets_s.push_back(member->center_s - centre.center_s);
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));
}
const auto weights = frame_weights(offsets_s);
const Grid grid = scene_grid(settings);
@@ -637,8 +700,9 @@ struct Sar3dProcessor::State {
// A Tx+Rx path is convex, so the box corners bound it: samples beyond the
// longest path are never read and are not sent to the GPU.
const double samples_per_metre = 1.0 / (kWaveSpeed * newest->dt_s);
const std::size_t samples =
std::min(newest->samples, static_cast<std::size_t>(std::ceil(longest_path * samples_per_metre)) + 2U);
const double needed = std::ceil(longest_path * samples_per_metre) + 2.0;
const std::size_t samples = needed >= newest->samples
? newest->samples : static_cast<std::size_t>(needed);
std::vector<std::complex<float>> ascans{};
for (const auto& member : aperture) {
const std::span<const std::complex<float>> rows = member->ascans;
@@ -649,7 +713,7 @@ struct Sar3dProcessor::State {
}
const Oblique oblique = oblique_view(grid, settings.voxel_m);
auto images = gpu.project({
auto images = backend.project({
.x = grid.x,
.y = grid.y,
.z = grid.z,
@@ -664,6 +728,7 @@ struct Sar3dProcessor::State {
.use_compensation = settings.use_compensation,
.comp_range_power = settings.comp_range_power,
.comp_weight_max = kCompRangeWeightMax,
.norm_edge_margin_m = kNormEdgeMargin,
.oblique = oblique.view,
});
@@ -671,30 +736,17 @@ struct Sar3dProcessor::State {
// edges, where a constant-range ridge leaves the grid and would set a
// false maximum (sar_norm_reference_mask, sar_finish_volume,
// sar_global_max_index). The level is the volume maximum, so the
// published projections peak at 1.
const std::size_t nx = grid.x.size();
const std::size_t nz = grid.z.size();
float level = 0.0F;
std::size_t peak_x = 0U;
std::size_t peak_y = 0U;
for (std::size_t iy = 0U; iy < grid.y.size(); ++iy) {
if (grid.y[iy] < grid.y.front() + kNormEdgeMargin || grid.y[iy] > grid.y.back() - kNormEdgeMargin) {
continue;
}
for (std::size_t ix = 0U; ix < nx; ++ix) {
if (images.front[iy * nx + ix] > level) {
level = images.front[iy * nx + ix];
peak_x = ix;
peak_y = iy;
}
}
}
const auto side_row = std::span<const float>(images.side).subspan(peak_y * nz, nz);
const auto peak_z = static_cast<std::size_t>(std::ranges::max_element(side_row) - side_row.begin());
const float scale = level > 0.0F ? 1.0F / level : 0.0F;
// Values at excluded Y edges can exceed 1, as in sar_finish_volume.
const float level = images.level;
const auto [peak_y, peak_z, peak_x] = images.peak;
// The notebook normalizes before and after the linear Gaussian pass.
// Combining both factors in double preserves its epsilons and avoids
// float32 reciprocal overflow for a weak, nonzero signal.
const double normalization = static_cast<double>(level) + (images.raw_level + 1e-12) * 1e-12;
for (auto* image : {&images.front, &images.side, &images.top, &images.oblique}) {
for (auto& value : *image) {
value *= scale;
value = static_cast<float>(static_cast<double>(value) / normalization);
if (!std::isfinite(value)) { throw std::runtime_error("SAR normalization exceeds the float32 range"); }
}
}
@@ -707,10 +759,12 @@ struct Sar3dProcessor::State {
basis.insert(basis.end(), oblique.view.v.begin(), oblique.view.v.end());
payloads.push_back(table_payload("sar3d_oblique_basis", 3U, std::move(basis)));
if (level > 0.0F) {
payloads.push_back(table_payload("sar3d_peak", 4U, {level, static_cast<float>(grid.x[peak_x]),
payloads.push_back(table_payload("sar3d_peak", 4U, {static_cast<float>(normalization), static_cast<float>(grid.x[peak_x]),
static_cast<float>(grid.y[peak_y]),
static_cast<float>(grid.z[peak_z])}));
}
payloads.push_back(status_payload(level > 0.0F ? 0.0F : 1.0F,
level > 0.0F ? "CUDA" : "No signal inside the selected depth range"));
return payloads;
}
@@ -748,10 +802,13 @@ auto Sar3dProcessor::process_collection(
try {
results.collection_payloads =
state_->render(collection, previous_collections, read_settings(run_config, live_config));
} catch (const std::exception& error) {
// A data or CUDA error must not stop the data_processor loop: publish an
// empty result and keep going.
} catch (const sar3d::CudaUnavailable& error) {
state_->report_error(error.what());
results.collection_payloads = {status_payload(3.0F, error.what())};
} catch (const std::exception& error) {
// Keep the processing loop alive and make the reason visible in the GUI.
state_->report_error(error.what());
results.collection_payloads = {status_payload(2.0F, error.what())};
}
return results;
}
@@ -35,18 +35,6 @@ class RadarDriver {
/** @brief Acquire one sweep containing the forward traces exposed by the driver. */
[[nodiscard]] virtual auto acquire_sweep() -> SweepTrace = 0;
/**
* @brief Announce which switch combo the next `acquire_sweep` belongs to.
*
* Real radars are agnostic to this because the switch state itself decides
* what they see. Mock drivers use it to synthesise per-combo variation so
* downstream plots show eight distinct traces for an eight-combo run
* instead of eight identical curves stacked on top of each other.
*
* Default implementation is a no-op so production drivers do not need to
* override.
*/
virtual void set_active_combo(const ipc::ComboKey& /*combo*/) {}
};
} // namespace radar::drivers
@@ -1,414 +0,0 @@
#include "../compact_m_k209_driver.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstring>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <utility>
namespace radar::drivers {
namespace {
constexpr std::uint32_t kFloatBytes = 4U;
constexpr std::uint32_t kComplexScalarCount = 2U;
constexpr std::uint64_t kMaxBinaryBlockBytes = 512ULL * 1024ULL * 1024ULL;
[[nodiscard]] auto visa_resource(const std::string& resource) -> ViRsrc {
return reinterpret_cast<ViRsrc>(const_cast<char*>(resource.c_str()));
}
[[nodiscard]] auto visa_buffer(std::uint8_t* data) -> ViBuf {
return reinterpret_cast<ViBuf>(data);
}
[[nodiscard]] auto visa_const_buffer(const char* data) -> ViBuf {
return reinterpret_cast<ViBuf>(const_cast<char*>(data));
}
[[nodiscard]] auto is_line_ending(std::uint8_t value) -> bool {
return value == static_cast<std::uint8_t>('\n') || value == static_cast<std::uint8_t>('\r');
}
[[nodiscard]] auto is_response_separator(std::uint8_t value) -> bool {
return is_line_ending(value) || value == static_cast<std::uint8_t>(';');
}
[[nodiscard]] auto checked_vi_count(std::uint64_t value) -> ViUInt32 {
const auto max_count = static_cast<std::uint64_t>(std::numeric_limits<ViUInt32>::max());
return static_cast<ViUInt32>(std::min(value, max_count));
}
} // namespace
CompactMK209Driver::CompactMK209Driver(CompactMK209DriverSettings settings) : settings_(std::move(settings)) {}
CompactMK209Driver::~CompactMK209Driver() {
try {
close();
} catch (...) {
}
}
void CompactMK209Driver::open() {
if (is_open_) {
return;
}
if (settings_.resource.empty()) {
throw std::runtime_error("K209 VISA resource must not be empty");
}
if (settings_.sweep.points < 2U) {
throw std::runtime_error("K209 sweep points must be >= 2");
}
if (settings_.sweep.stop_hz < settings_.sweep.start_hz) {
throw std::runtime_error("K209 sweep stop_hz must be >= start_hz");
}
if (!(settings_.sweep.if_bandwidth_hz > 0.0F)) {
throw std::runtime_error("K209 IF bandwidth must be > 0");
}
ViStatus status = viOpenDefaultRM(&resource_manager_);
if (status < VI_SUCCESS) {
throw std::runtime_error("Failed to open VISA resource manager: status=" + std::to_string(status));
}
try {
check_status(
viOpen(resource_manager_, visa_resource(settings_.resource), VI_NULL, VI_NULL, &instrument_),
"open K209 VISA resource"
);
check_status(
viSetAttribute(instrument_, VI_ATTR_TMO_VALUE, static_cast<ViAttrState>(settings_.timeout_ms)),
"set K209 VISA timeout"
);
check_status(
viSetAttribute(instrument_, VI_ATTR_TERMCHAR_EN, static_cast<ViAttrState>(VI_FALSE)),
"disable K209 VISA termchar"
);
write_command("*CLS");
configure_device();
} catch (...) {
close();
throw;
}
is_open_ = true;
}
void CompactMK209Driver::close() {
if (instrument_ != VI_NULL) {
viClose(instrument_);
instrument_ = VI_NULL;
}
if (resource_manager_ != VI_NULL) {
viClose(resource_manager_);
resource_manager_ = VI_NULL;
}
is_open_ = false;
}
auto CompactMK209Driver::acquire_sweep() -> SweepTrace {
require_open();
auto traces = query_sweep_trace_pair(settings_.sweep.points);
SweepTrace trace{};
trace.frequency_hz = frequency_hz_;
trace.s11 = complex_trace_from_interleaved(traces.first);
trace.s21 = complex_trace_from_interleaved(traces.second);
return trace;
}
auto CompactMK209Driver::query_identity() -> std::string {
require_open();
return trim_ascii(query_string("*IDN?"));
}
auto CompactMK209Driver::query_system_error() -> std::string {
require_open();
return trim_ascii(query_string("SYST:ERR?"));
}
void CompactMK209Driver::configure_device() {
if (settings_.preset_on_open) {
write_command("SYST:PRES");
expect_operation_complete("*OPC?", "wait for K209 preset");
}
write_command("SENS:FREQ:STAR " + format_frequency(settings_.sweep.start_hz));
write_command("SENS:FREQ:STOP " + format_frequency(settings_.sweep.stop_hz));
write_command("SENS:SWE:POIN " + std::to_string(settings_.sweep.points));
write_command("SENS:SWE:POIN:TIME 0");
write_command("SENS:BAND " + format_frequency(settings_.sweep.if_bandwidth_hz));
write_command("SOUR:POW " + format_power(settings_.sweep.power_dbm));
write_command("SENS:AVER OFF");
write_command("CALC:PAR1:DEF S21");
write_command("CALC:PAR1:SEL");
write_command("FORM:DATA REAL32");
write_command("FORM:BORD SWAP");
write_command("INIT:CONT ON");
write_command("TRIG:SOUR BUS");
expect_operation_complete("*OPC?", "wait for K209 setup");
frequency_hz_ = query_float_array("SENS:FREQ:DATA?", settings_.sweep.points);
}
void CompactMK209Driver::expect_operation_complete(const std::string& command, const std::string& context) {
const auto response = trim_ascii(query_string(command));
if (response != "1") {
throw std::runtime_error(context + " returned unexpected *OPC? response: " + response);
}
}
void CompactMK209Driver::write_command(const std::string& command) {
require_open();
const std::string message = command + "\n";
ViUInt32 written = 0;
check_status(
viWrite(instrument_, visa_const_buffer(message.data()), static_cast<ViUInt32>(message.size()), &written),
"write K209 command: " + command
);
if (written != message.size()) {
throw std::runtime_error("Incomplete K209 command write: " + command);
}
}
auto CompactMK209Driver::query_string(const std::string& command) -> std::string {
write_command(command);
return read_line();
}
auto CompactMK209Driver::query_float_array(
const std::string& command,
std::uint32_t expected_values
) -> std::vector<float> {
write_command(command);
return read_float_array_response(command, expected_values);
}
auto CompactMK209Driver::query_sweep_trace_pair(std::uint32_t points)
-> std::pair<std::vector<float>, std::vector<float>> {
write_command("TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21");
const auto opc_response = read_ascii_token();
if (opc_response != "1") {
throw std::runtime_error("K209 sweep returned unexpected *OPC? response: " + opc_response);
}
return {
read_float_array_response("SENS:DATA:CORR? S11", points * kComplexScalarCount),
read_float_array_response("SENS:DATA:CORR? S21", points * kComplexScalarCount),
};
}
auto CompactMK209Driver::read_float_array_response(
const std::string& context,
std::uint32_t expected_values
) -> std::vector<float> {
auto values = float_array_from_little_endian(read_ieee_block());
if (values.size() != expected_values) {
throw std::runtime_error(
"K209 response for " + context + " returned " + std::to_string(values.size()) +
" float32 values, expected " + std::to_string(expected_values)
);
}
return values;
}
auto CompactMK209Driver::read_ascii_token() -> std::string {
require_open();
std::string token{};
token.reserve(32);
while (true) {
const auto byte = read_byte();
if (is_response_separator(byte)) {
if (!token.empty()) {
return token;
}
continue;
}
token.push_back(static_cast<char>(byte));
if (token.size() > 64U * 1024U) {
throw std::runtime_error("K209 ASCII response token is too long");
}
}
}
auto CompactMK209Driver::read_line() -> std::string {
require_open();
std::string line{};
line.reserve(128);
bool has_content = false;
while (true) {
const auto byte = read_byte();
if (!has_content && is_line_ending(byte)) {
continue;
}
if (byte == static_cast<std::uint8_t>('\n')) {
break;
}
if (byte != static_cast<std::uint8_t>('\r')) {
line.push_back(static_cast<char>(byte));
has_content = true;
}
if (line.size() > 64U * 1024U) {
throw std::runtime_error("K209 ASCII response line is too long");
}
}
return line;
}
auto CompactMK209Driver::read_byte() -> std::uint8_t {
auto bytes = read_exact(1);
return bytes[0];
}
auto CompactMK209Driver::read_exact(std::uint64_t size) -> std::vector<std::uint8_t> {
require_open();
std::vector<std::uint8_t> bytes(size);
std::uint64_t offset = 0;
while (offset < size) {
ViUInt32 transferred = 0;
const auto count = checked_vi_count(size - offset);
check_status(
viRead(instrument_, visa_buffer(bytes.data() + offset), count, &transferred),
"read K209 response bytes"
);
if (transferred == 0U) {
throw std::runtime_error("K209 VISA read returned zero bytes before expected payload was complete");
}
offset += transferred;
}
return bytes;
}
auto CompactMK209Driver::read_ieee_block() -> std::vector<std::uint8_t> {
std::uint8_t marker = read_byte();
while (is_response_separator(marker)) {
marker = read_byte();
}
if (marker != static_cast<std::uint8_t>('#')) {
throw std::runtime_error("K209 binary response does not start with IEEE block marker '#'");
}
const auto digit = read_byte();
if (digit != static_cast<std::uint8_t>('8')) {
throw std::runtime_error("K209 binary response uses unsupported IEEE block header width");
}
const auto size_text_bytes = read_exact(8);
std::uint64_t payload_size = 0;
for (const auto byte : size_text_bytes) {
if (!std::isdigit(static_cast<unsigned char>(byte))) {
throw std::runtime_error("K209 binary response has non-numeric payload size");
}
payload_size = (payload_size * 10ULL) + static_cast<std::uint64_t>(byte - static_cast<std::uint8_t>('0'));
}
if (payload_size == 0U || payload_size > kMaxBinaryBlockBytes) {
throw std::runtime_error("K209 binary response payload size is out of allowed range");
}
if ((payload_size % kFloatBytes) != 0U) {
throw std::runtime_error("K209 binary response payload size is not aligned to float32 values");
}
return read_exact(payload_size);
}
void CompactMK209Driver::check_status(ViStatus status, const std::string& context) const {
if (status >= VI_SUCCESS) {
return;
}
throw std::runtime_error(context + ": " + status_message(status));
}
auto CompactMK209Driver::status_message(ViStatus status) const -> std::string {
std::array<ViChar, 256> description{};
const auto session = instrument_ != VI_NULL ? instrument_ : resource_manager_;
if (session != VI_NULL && viStatusDesc(session, status, description.data()) >= VI_SUCCESS) {
return std::string(description.data()) + " (status=" + std::to_string(status) + ")";
}
return "VISA status=" + std::to_string(status);
}
void CompactMK209Driver::require_open() const {
if (instrument_ == VI_NULL) {
throw std::runtime_error("K209 VISA instrument is not open");
}
}
auto CompactMK209Driver::float_array_from_little_endian(std::vector<std::uint8_t> bytes) -> std::vector<float> {
if ((bytes.size() % kFloatBytes) != 0U) {
throw std::runtime_error("K209 binary float payload is not aligned to 4 bytes");
}
std::vector<float> values(bytes.size() / kFloatBytes, 0.0F);
for (std::size_t index = 0; index < values.size(); ++index) {
const auto offset = index * kFloatBytes;
const std::uint32_t bits = static_cast<std::uint32_t>(bytes[offset]) |
(static_cast<std::uint32_t>(bytes[offset + 1U]) << 8U) |
(static_cast<std::uint32_t>(bytes[offset + 2U]) << 16U) |
(static_cast<std::uint32_t>(bytes[offset + 3U]) << 24U);
std::memcpy(&values[index], &bits, sizeof(float));
}
return values;
}
auto CompactMK209Driver::complex_trace_from_interleaved(const std::vector<float>& values)
-> std::vector<ipc::Complex32> {
if ((values.size() % kComplexScalarCount) != 0U) {
throw std::runtime_error("K209 complex trace payload has odd scalar count");
}
std::vector<ipc::Complex32> trace{};
trace.reserve(values.size() / kComplexScalarCount);
for (std::size_t index = 0; index < values.size(); index += kComplexScalarCount) {
trace.push_back(ipc::Complex32{.re = values[index], .im = values[index + 1U]});
}
return trace;
}
auto CompactMK209Driver::format_frequency(float value_hz) -> std::string {
if (!std::isfinite(value_hz)) {
throw std::runtime_error("K209 frequency value is not finite");
}
std::ostringstream stream;
stream.precision(9);
stream << std::fixed << value_hz;
return stream.str();
}
auto CompactMK209Driver::format_power(float value_dbm) -> std::string {
if (!std::isfinite(value_dbm)) {
throw std::runtime_error("K209 power value is not finite");
}
std::ostringstream stream;
stream.precision(3);
stream << std::fixed << value_dbm;
return stream.str();
}
auto CompactMK209Driver::trim_ascii(std::string value) -> std::string {
const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
});
const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}).base();
if (first >= last) {
return {};
}
return std::string(first, last);
}
} // namespace radar::drivers
@@ -1,71 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include <visa.h>
#include "radar_driver.hpp"
#include "run_config.hpp"
namespace radar::drivers {
struct CompactMK209DriverSettings {
std::string resource{};
config::RadarSweepSettings sweep{};
std::uint32_t timeout_ms = 20'000;
bool preset_on_open = true;
};
class CompactMK209Driver final : public RadarDriver {
public:
explicit CompactMK209Driver(CompactMK209DriverSettings settings);
~CompactMK209Driver() override;
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
[[nodiscard]] auto query_identity() -> std::string;
[[nodiscard]] auto query_system_error() -> std::string;
private:
void configure_device();
void expect_operation_complete(const std::string& command, const std::string& context);
void write_command(const std::string& command);
[[nodiscard]] auto query_string(const std::string& command) -> std::string;
[[nodiscard]] auto query_float_array(const std::string& command, std::uint32_t expected_values)
-> std::vector<float>;
[[nodiscard]] auto query_sweep_trace_pair(std::uint32_t points)
-> std::pair<std::vector<float>, std::vector<float>>;
[[nodiscard]] auto read_float_array_response(const std::string& context, std::uint32_t expected_values)
-> std::vector<float>;
[[nodiscard]] auto read_ascii_token() -> std::string;
[[nodiscard]] auto read_line() -> std::string;
[[nodiscard]] auto read_byte() -> std::uint8_t;
[[nodiscard]] auto read_exact(std::uint64_t size) -> std::vector<std::uint8_t>;
[[nodiscard]] auto read_ieee_block() -> std::vector<std::uint8_t>;
void check_status(ViStatus status, const std::string& context) const;
[[nodiscard]] auto status_message(ViStatus status) const -> std::string;
void require_open() const;
[[nodiscard]] static auto float_array_from_little_endian(std::vector<std::uint8_t> bytes) -> std::vector<float>;
[[nodiscard]] static auto complex_trace_from_interleaved(const std::vector<float>& values)
-> std::vector<ipc::Complex32>;
[[nodiscard]] static auto format_frequency(float value_hz) -> std::string;
[[nodiscard]] static auto format_power(float value_dbm) -> std::string;
[[nodiscard]] static auto trim_ascii(std::string value) -> std::string;
CompactMK209DriverSettings settings_{};
std::vector<float> frequency_hz_{};
bool is_open_ = false;
ViSession resource_manager_ = VI_NULL;
ViSession instrument_ = VI_NULL;
};
} // namespace radar::drivers
@@ -3,15 +3,11 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <complex>
#include <cstdint>
#include <exception>
#include <random>
#include <string>
#include <string_view>
#include <stdexcept>
#include <thread>
#include <utility>
#include <vector>
@@ -43,60 +39,6 @@ constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
);
}
// One synthetic GPR reflector. `range_m` is its physical depth, `reflection`
// is the dimensionless complex reflection coefficient (|Γ| ≤ 1).
struct MockTarget {
float range_m;
float reflection_magnitude;
};
// Three reflectors at GPR-typical depths: a strong near-surface scatterer,
// a mid-depth target, and a weak deeper one. The magnitudes are tuned so the
// summed S21 stays within unit modulus across the sweep band.
constexpr std::array<MockTarget, 3> kMockTargets = {{
{0.45F, 0.55F},
{1.30F, 0.30F},
{2.90F, 0.18F},
}};
// Group velocity in moderately wet soil (≈ c / sqrt(εr), εr ≈ 4). The choice
// is what maps reflector depth to round-trip phase delay; it is held constant
// to keep the simulator deterministic.
constexpr float kGroundVelocityMps = 1.5e8F;
// Soil attenuation grows with frequency. Calibrated so a target at 3 m sees
// roughly −20 dB extra loss at 6 GHz on top of geometric spreading.
constexpr float kAttenuationCoeffPerMeterAtRefHz = 0.22F;
constexpr float kAttenuationReferenceHz = 6e9F;
// Antenna mismatch dominates S11: simulate one shallow reflection right at
// the connector, plus a small amount of cross-coupling from S21 targets.
constexpr float kS11ConnectorReflection = 0.55F;
constexpr float kS11ConnectorRangeM = 0.02F;
constexpr float kS11CrossCouplingFactor = 0.06F;
// Noise floor in linear voltage units. Real LibreVNA hits ~ −90 dB at 1 kHz
// IFBW; pick something a touch noisier so the GPR processor has to work.
constexpr float kNoiseAmplitudeLinear = 0.004F;
// Minimum simulated dwell so 0-point or pathological configs don't busy-loop.
constexpr auto kMockMinimumSweepDuration = std::chrono::microseconds(50);
// Compute the dwell time the mock pretends to spend on the device. Mirrors
// the real LibreVNA contract: per-point dwell ≈ 1 / IFBW. Capped so absurd
// configs (IFBW ≈ 0 or huge sweeps) cannot freeze the producer for hours.
[[nodiscard]] auto mock_target_sweep_duration(const config::RadarSweepSettings& sweep)
-> std::chrono::nanoseconds {
const float points = std::max(1.0F, static_cast<float>(sweep.points));
const float if_bw = std::max(1.0F, sweep.if_bandwidth_hz);
const double seconds = static_cast<double>(points) / static_cast<double>(if_bw);
const auto duration_ns = std::chrono::nanoseconds(
static_cast<std::chrono::nanoseconds::rep>(seconds * 1e9)
);
constexpr auto kHardCap = std::chrono::seconds(5);
return std::clamp<std::chrono::nanoseconds>(duration_ns, kMockMinimumSweepDuration, kHardCap);
}
[[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool {
// A stop request must not be retried: drain to the caller immediately (#26).
if (message.find("aborted by stop request") != std::string_view::npos) {
@@ -139,9 +81,7 @@ void LibreVnaMinimalDriver::open() {
throw std::runtime_error("Radar sweep points must be > 0");
}
if (settings_.mode == config::DriverMode::Native) {
open_native();
}
open_native();
is_open_ = true;
}
@@ -151,166 +91,58 @@ void LibreVnaMinimalDriver::close() {
return;
}
if (settings_.mode == config::DriverMode::Native) {
close_native();
}
close_native();
is_open_ = false;
}
void LibreVnaMinimalDriver::set_active_combo(const ipc::ComboKey& combo) {
active_combo_ = combo;
}
auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
if (!is_open_) {
throw std::runtime_error("Radar driver is not open");
}
++sweep_index_;
switch (settings_.mode) {
case config::DriverMode::Mock:
return acquire_mock();
case config::DriverMode::Native: {
std::exception_ptr last_exception{};
std::string last_message{};
std::exception_ptr last_exception{};
std::string last_message{};
for (std::uint32_t attempt = 1; attempt <= kNativeAcquireMaxAttempts; ++attempt) {
try {
return acquire_native();
} catch (const std::exception& exception) {
last_exception = std::current_exception();
last_message = exception.what();
for (std::uint32_t attempt = 1; attempt <= kNativeAcquireMaxAttempts; ++attempt) {
try {
return acquire_native();
} catch (const std::exception& exception) {
last_exception = std::current_exception();
last_message = exception.what();
if (!is_retryable_native_acquire_error(last_message) || attempt == kNativeAcquireMaxAttempts) {
break;
}
if (detail::native_stop_requested()) {
break; // #26: do not waste reconnect attempts while shutting down.
}
// #31: recover from transient USB/protocol stalls without
// destroying the libusb_context. Release only the interface
// and handle, then reopen against the surviving context so
// we ride out a brief device re-enumeration instead of
// re-initialising libusb from scratch on every retry.
rx_buffer_.clear();
packet_queue_.clear();
if (usb_handle_ != nullptr && interface_claimed_) {
libusb_release_interface(usb_handle_, detail::kUsbInterface);
interface_claimed_ = false;
}
if (usb_handle_ != nullptr) {
libusb_close(usb_handle_);
usb_handle_ = nullptr;
}
protocol_version_ = 0;
device_num_ports_ = 0;
open_native(); // Reuses the surviving usb_context_ and retries discovery.
}
if (!is_retryable_native_acquire_error(last_message) || attempt == kNativeAcquireMaxAttempts) {
break;
}
if (detail::native_stop_requested()) {
break; // #26: do not waste reconnect attempts while shutting down.
}
if (last_exception != nullptr) {
std::rethrow_exception(last_exception);
// #31: recover from transient USB/protocol stalls without
// destroying the libusb_context. Release only the interface
// and handle, then reopen against the surviving context so
// we ride out a brief device re-enumeration instead of
// re-initialising libusb from scratch on every retry.
rx_buffer_.clear();
packet_queue_.clear();
if (usb_handle_ != nullptr && interface_claimed_) {
libusb_release_interface(usb_handle_, detail::kUsbInterface);
interface_claimed_ = false;
}
throw std::runtime_error("Native acquisition failed without exception detail");
if (usb_handle_ != nullptr) {
libusb_close(usb_handle_);
usb_handle_ = nullptr;
}
protocol_version_ = 0;
device_num_ports_ = 0;
open_native(); // Reuses the surviving usb_context_ and retries discovery.
}
default:
throw std::runtime_error("Unsupported radar driver mode");
}
}
auto LibreVnaMinimalDriver::acquire_mock() -> SweepTrace {
// Synthesise a frequency-domain VNA response containing several discrete
// reflectors at known depths, plus light Gaussian noise. The sweep dwells
// for a realistic duration derived from the configured IF bandwidth so
// downstream stages cannot be flooded faster than a real device would
// produce data.
const auto started_at = std::chrono::steady_clock::now();
const auto target_duration = mock_target_sweep_duration(settings_.sweep);
SweepTrace trace{};
trace.frequency_hz.reserve(settings_.sweep.points);
trace.s11.reserve(settings_.sweep.points);
trace.s21.reserve(settings_.sweep.points);
const float span_hz = settings_.sweep.stop_hz - settings_.sweep.start_hz;
const float denominator =
settings_.sweep.points > 1U ? static_cast<float>(settings_.sweep.points - 1U) : 1.0F;
// A tiny per-sweep range drift gives the rendered B-scan a visible motion
// signature so the simulator does not look frozen.
const float range_drift_m =
0.01F * std::sin(0.07F * static_cast<float>(sweep_index_));
// Combo-dependent variation. Without this the mock returns near-identical
// S21 for every (input, output) combo and an eight-combo pass-through plot
// collapses into a single visible trace. The factors below are arbitrary
// but chosen small enough that the overall response stays in a reasonable
// band and large enough that each pair is visually distinct.
const auto input_pos = static_cast<float>(active_combo_.input_pos);
const auto output_pos = static_cast<float>(active_combo_.output_pos);
const float combo_amplitude_gain = 0.55F + 0.08F * input_pos + 0.05F * output_pos;
const float combo_phase_offset = 0.4F * input_pos + 0.9F * output_pos;
const float combo_range_offset_m = 0.05F * input_pos + 0.12F * output_pos;
// Deterministic-per-sweep noise so two consecutive frames look distinct
// but the test stays reproducible for any given sweep index.
std::mt19937 noise_engine(
static_cast<std::uint32_t>(0x9E3779B9ULL ^ sweep_index_)
);
std::normal_distribution<float> noise_dist(0.0F, kNoiseAmplitudeLinear);
for (std::uint32_t point = 0; point < settings_.sweep.points; ++point) {
const float ratio = static_cast<float>(point) / denominator;
const float frequency_hz = settings_.sweep.start_hz + span_hz * ratio;
const float frequency_scale = frequency_hz / kAttenuationReferenceHz;
std::complex<float> s21_total{0.0F, 0.0F};
std::complex<float> s11_total{0.0F, 0.0F};
for (const auto& target : kMockTargets) {
const float range_m = target.range_m + range_drift_m + combo_range_offset_m;
// Round-trip phase: 2π·f·(2R/v).
const float round_trip_phase =
2.0F * detail::kPi * frequency_hz * (2.0F * range_m / kGroundVelocityMps);
// Geometric spreading: 1/(1 + R) keeps near-zero ranges finite.
const float spreading = 1.0F / (1.0F + range_m);
// Frequency-dependent soil attenuation in linear amplitude.
const float attenuation =
std::exp(-kAttenuationCoeffPerMeterAtRefHz * range_m * frequency_scale);
const std::complex<float> contribution = std::polar<float>(
target.reflection_magnitude * spreading * attenuation * combo_amplitude_gain,
-round_trip_phase + combo_phase_offset
);
s21_total += contribution;
s11_total += kS11CrossCouplingFactor * contribution;
}
// Antenna mismatch dominates the near-field S11 response.
const float antenna_phase =
2.0F * detail::kPi * frequency_hz * (2.0F * kS11ConnectorRangeM / kGroundVelocityMps);
s11_total += std::polar<float>(kS11ConnectorReflection, -antenna_phase);
// Independent noise per channel; complex variance ≈ kNoiseAmplitudeLinear².
s21_total += std::complex<float>(noise_dist(noise_engine), noise_dist(noise_engine));
s11_total += std::complex<float>(noise_dist(noise_engine), noise_dist(noise_engine));
trace.frequency_hz.push_back(frequency_hz);
trace.s11.push_back({.re = s11_total.real(), .im = s11_total.imag()});
trace.s21.push_back({.re = s21_total.real(), .im = s21_total.imag()});
}
// Honour the IFBW-derived dwell time. If generation already took longer
// than the simulated device would have needed (huge `points` × CPU jitter)
// we skip the sleep so the producer does not fall further behind.
const auto elapsed = std::chrono::steady_clock::now() - started_at;
if (elapsed < target_duration) {
std::this_thread::sleep_for(target_duration - elapsed);
if (last_exception != nullptr) {
std::rethrow_exception(last_exception);
}
return trace;
throw std::runtime_error("Native acquisition failed without exception detail");
}
auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
@@ -23,10 +23,8 @@ namespace radar::drivers {
* @brief Runtime settings for minimal LibreVNA acquisition.
*/
struct LibreVnaMinimalDriverSettings {
config::DriverMode mode = config::DriverMode::Mock;
std::string serial{};
config::RadarSweepSettings sweep{};
float mock_signal_hz = 5'000'000.0F;
};
/**
@@ -43,7 +41,6 @@ class LibreVnaMinimalDriver final : public RadarDriver {
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
void set_active_combo(const ipc::ComboKey& combo) override;
private:
/**
@@ -66,7 +63,6 @@ class LibreVnaMinimalDriver final : public RadarDriver {
bool has_s21 = false;
};
[[nodiscard]] auto acquire_mock() -> SweepTrace;
[[nodiscard]] auto acquire_native() -> SweepTrace;
void open_native();
@@ -92,11 +88,6 @@ class LibreVnaMinimalDriver final : public RadarDriver {
LibreVnaMinimalDriverSettings settings_{};
bool is_open_ = false;
std::uint64_t sweep_index_ = 0;
// Latest combo announced by the orchestrator. Used by the mock backend to
// give each (input, output) pair a slightly different reflectivity profile
// so a multi-combo run does not render as eight identical traces.
ipc::ComboKey active_combo_{};
libusb_context* usb_context_ = nullptr;
libusb_device_handle* usb_handle_ = nullptr;
@@ -1,271 +0,0 @@
#include "../remote_compact_m_k209_driver.hpp"
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace radar::drivers {
namespace {
constexpr std::uint8_t kCommandConfigure = static_cast<std::uint8_t>('C');
constexpr std::uint8_t kCommandAcquire = static_cast<std::uint8_t>('A');
constexpr std::uint8_t kStatusOk = static_cast<std::uint8_t>('O');
constexpr std::uint8_t kStatusError = static_cast<std::uint8_t>('E');
[[nodiscard]] auto system_error_message(const std::string& context) -> std::string {
return context + ": " + std::strerror(errno);
}
void append_u32_be(std::vector<std::uint8_t>& buffer, std::uint32_t value) {
buffer.push_back(static_cast<std::uint8_t>((value >> 24U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 16U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 8U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>(value & 0xFFU));
}
void append_u64_be(std::vector<std::uint8_t>& buffer, std::uint64_t value) {
for (int shift = 56; shift >= 0; shift -= 8) {
buffer.push_back(static_cast<std::uint8_t>((value >> static_cast<unsigned>(shift)) & 0xFFU));
}
}
void append_double_be(std::vector<std::uint8_t>& buffer, double value) {
std::uint64_t bits = 0;
static_assert(sizeof(bits) == sizeof(value));
std::memcpy(&bits, &value, sizeof(bits));
append_u64_be(buffer, bits);
}
[[nodiscard]] auto parse_u32_be(const std::uint8_t* data) -> std::uint32_t {
return (static_cast<std::uint32_t>(data[0]) << 24U) |
(static_cast<std::uint32_t>(data[1]) << 16U) |
(static_cast<std::uint32_t>(data[2]) << 8U) |
static_cast<std::uint32_t>(data[3]);
}
void send_all(int socket_fd, const void* data, std::size_t size) {
const auto* cursor = static_cast<const std::uint8_t*>(data);
auto remaining = size;
while (remaining > 0U) {
const auto sent = ::send(socket_fd, cursor, remaining, MSG_NOSIGNAL);
if (sent < 0) {
throw std::runtime_error(system_error_message("send to K209 remote server failed"));
}
if (sent == 0) {
throw std::runtime_error("send to K209 remote server returned zero bytes");
}
cursor += sent;
remaining -= static_cast<std::size_t>(sent);
}
}
void recv_exact(int socket_fd, void* data, std::size_t size) {
auto* cursor = static_cast<std::uint8_t*>(data);
auto remaining = size;
while (remaining > 0U) {
const auto received = ::recv(socket_fd, cursor, remaining, 0);
if (received < 0) {
throw std::runtime_error(system_error_message("read from K209 remote server failed"));
}
if (received == 0) {
throw std::runtime_error("K209 remote server closed the connection");
}
cursor += received;
remaining -= static_cast<std::size_t>(received);
}
}
[[nodiscard]] auto recv_u32(int socket_fd) -> std::uint32_t {
std::uint8_t bytes[4]{};
recv_exact(socket_fd, bytes, sizeof(bytes));
return parse_u32_be(bytes);
}
void read_status(int socket_fd) {
std::uint8_t status = 0;
recv_exact(socket_fd, &status, 1);
if (status == kStatusOk) {
return;
}
if (status == kStatusError) {
const auto message_size = recv_u32(socket_fd);
std::string message(message_size, '\0');
if (message_size > 0U) {
recv_exact(socket_fd, message.data(), message.size());
}
throw std::runtime_error("K209 remote server error: " + message);
}
throw std::runtime_error("K209 remote server returned invalid status byte");
}
[[nodiscard]] auto recv_float32_array(int socket_fd, std::uint32_t expected_values, const std::string& context)
-> std::vector<float> {
const auto payload_size = recv_u32(socket_fd);
const auto expected_size = expected_values * static_cast<std::uint32_t>(sizeof(float));
if (payload_size != expected_size) {
throw std::runtime_error(
"K209 remote " + context + " payload has " + std::to_string(payload_size) +
" bytes, expected " + std::to_string(expected_size)
);
}
std::vector<float> values(expected_values);
if (payload_size > 0U) {
recv_exact(socket_fd, values.data(), payload_size);
}
return values;
}
[[nodiscard]] auto connect_socket(const std::string& host, std::uint32_t port, std::uint32_t timeout_ms) -> int {
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
addrinfo* result = nullptr;
const auto port_text = std::to_string(port);
const auto rc = ::getaddrinfo(host.c_str(), port_text.c_str(), &hints, &result);
if (rc != 0) {
throw std::runtime_error("getaddrinfo failed for K209 remote host " + host + ": " + ::gai_strerror(rc));
}
int connected_fd = -1;
std::string last_error;
for (auto* item = result; item != nullptr; item = item->ai_next) {
const int fd = ::socket(item->ai_family, item->ai_socktype, item->ai_protocol);
if (fd < 0) {
last_error = system_error_message("socket");
continue;
}
const int one = 1;
static_cast<void>(::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)));
timeval timeout{};
timeout.tv_sec = static_cast<long>(timeout_ms / 1000U);
timeout.tv_usec = static_cast<long>((timeout_ms % 1000U) * 1000U);
static_cast<void>(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)));
static_cast<void>(::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)));
if (::connect(fd, item->ai_addr, item->ai_addrlen) == 0) {
connected_fd = fd;
break;
}
last_error = system_error_message("connect");
::close(fd);
}
::freeaddrinfo(result);
if (connected_fd < 0) {
throw std::runtime_error("Failed to connect to K209 remote server " + host + ":" + port_text + ": " + last_error);
}
return connected_fd;
}
[[nodiscard]] auto complex_from_interleaved(const std::vector<float>& values, std::uint32_t points, const std::string& context)
-> std::vector<ipc::Complex32> {
if (values.size() != static_cast<std::size_t>(points) * 2U) {
throw std::runtime_error("K209 remote " + context + " returned unexpected scalar count");
}
std::vector<ipc::Complex32> output(points);
for (std::uint32_t index = 0; index < points; ++index) {
output[index] = ipc::Complex32{
.re = values[static_cast<std::size_t>(index) * 2U],
.im = values[static_cast<std::size_t>(index) * 2U + 1U],
};
}
return output;
}
} // namespace
RemoteCompactMK209Driver::RemoteCompactMK209Driver(RemoteCompactMK209DriverSettings settings)
: settings_(std::move(settings)) {
if (settings_.host.empty()) {
throw std::runtime_error("K209 remote host must not be empty");
}
if (settings_.port == 0U || settings_.port > 65535U) {
throw std::runtime_error("K209 remote port must be in 1..65535");
}
if (settings_.timeout_ms == 0U) {
throw std::runtime_error("K209 remote timeout_ms must be > 0");
}
}
RemoteCompactMK209Driver::~RemoteCompactMK209Driver() {
try {
close();
} catch (...) {
}
}
void RemoteCompactMK209Driver::open() {
if (socket_fd_ >= 0) {
return;
}
socket_fd_ = connect_socket(settings_.host, settings_.port, settings_.timeout_ms);
try {
std::vector<std::uint8_t> payload{};
payload.reserve(1U + 8U + 8U + 4U + 8U + 8U);
payload.push_back(kCommandConfigure);
append_double_be(payload, static_cast<double>(settings_.sweep.start_hz));
append_double_be(payload, static_cast<double>(settings_.sweep.stop_hz));
append_u32_be(payload, settings_.sweep.points);
append_double_be(payload, static_cast<double>(settings_.sweep.if_bandwidth_hz));
append_double_be(payload, static_cast<double>(settings_.sweep.power_dbm));
send_all(socket_fd_, payload.data(), payload.size());
read_status(socket_fd_);
const auto points = recv_u32(socket_fd_);
if (points != settings_.sweep.points) {
throw std::runtime_error("K209 remote server returned unexpected configured point count");
}
frequency_hz_ = recv_float32_array(socket_fd_, points, "frequency");
} catch (...) {
close();
throw;
}
}
void RemoteCompactMK209Driver::close() {
if (socket_fd_ >= 0) {
::close(socket_fd_);
socket_fd_ = -1;
}
frequency_hz_.clear();
}
auto RemoteCompactMK209Driver::acquire_sweep() -> SweepTrace {
if (socket_fd_ < 0) {
throw std::runtime_error("K209 remote driver is not open");
}
const std::uint8_t command = kCommandAcquire;
send_all(socket_fd_, &command, 1);
read_status(socket_fd_);
const auto points = recv_u32(socket_fd_);
if (points != settings_.sweep.points) {
throw std::runtime_error("K209 remote server returned unexpected sweep point count");
}
const auto s11_values = recv_float32_array(socket_fd_, points * 2U, "S11");
const auto s21_values = recv_float32_array(socket_fd_, points * 2U, "S21");
return SweepTrace{
.frequency_hz = frequency_hz_,
.s11 = complex_from_interleaved(s11_values, points, "S11"),
.s21 = complex_from_interleaved(s21_values, points, "S21"),
};
}
} // namespace radar::drivers
@@ -1,34 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "radar_driver.hpp"
#include "run_config.hpp"
namespace radar::drivers {
struct RemoteCompactMK209DriverSettings {
std::string host = "127.0.0.1";
std::uint32_t port = 50209;
config::RadarSweepSettings sweep{};
std::uint32_t timeout_ms = 20'000;
};
class RemoteCompactMK209Driver final : public RadarDriver {
public:
explicit RemoteCompactMK209Driver(RemoteCompactMK209DriverSettings settings);
~RemoteCompactMK209Driver() override;
void open() override;
void close() override;
[[nodiscard]] auto acquire_sweep() -> SweepTrace override;
private:
RemoteCompactMK209DriverSettings settings_{};
int socket_fd_ = -1;
std::vector<float> frequency_hz_{};
};
} // namespace radar::drivers
@@ -10,7 +10,6 @@
#include "hmc349a_minimal_driver.hpp"
#include "librevna_minimal_driver.hpp"
#include "radar_driver.hpp"
#include "remote_compact_m_k209_driver.hpp"
#include "run_config.hpp"
#include "shm_ring.hpp"
#include "sweep_orchestrator.hpp"
@@ -20,7 +19,6 @@ namespace {
constexpr const char* kDefaultConfigPath = "run_config.json";
constexpr const char* kLibreVnaModel = "librevna";
constexpr const char* kLibreVnaMultiModel = "librevna_multi";
constexpr const char* kCompactMK209Model = "compact_m_k209";
std::atomic<bool> g_stop_requested{false};
void signal_handler(int /*signal*/) {
@@ -48,26 +46,13 @@ void install_signal_handlers() {
[[nodiscard]] auto make_radar_driver(const radar::config::RadarConfig& config)
-> std::unique_ptr<radar::drivers::RadarDriver> {
if (config.model == kLibreVnaModel || config.model.empty()) {
if (config.driver_mode != radar::config::DriverMode::Native) {
throw std::runtime_error("LibreVNA simulation uses python_app.scripts.matrix_raw_producer; launch through start.sh");
}
return std::make_unique<radar::drivers::LibreVnaMinimalDriver>(
radar::drivers::LibreVnaMinimalDriverSettings{
.mode = config.driver_mode,
.serial = config.serial,
.sweep = config.sweep,
.mock_signal_hz = config.mock_signal_hz,
}
);
}
if (config.model == kCompactMK209Model) {
if (config.driver_mode != radar::config::DriverMode::Native) {
throw std::runtime_error("compact_m_k209 requires radar.driver_mode='native'");
}
return std::make_unique<radar::drivers::RemoteCompactMK209Driver>(
radar::drivers::RemoteCompactMK209DriverSettings{
.host = config.remote_host,
.port = config.remote_port,
.sweep = config.sweep,
.timeout_ms = 20'000,
}
);
}
@@ -76,10 +61,6 @@ void install_signal_handlers() {
throw std::runtime_error("librevna_multi is handled by python_app.scripts.matrix_raw_producer");
}
if (config.model == "sn9000") {
throw std::runtime_error("sn9000 is handled by python_app.scripts.matrix_raw_producer");
}
throw std::runtime_error("Unsupported radar.model for sweep_orchestrator: " + config.model);
}
@@ -1,12 +1,11 @@
#include "sweep_orchestrator.hpp"
#include "runtime_wait.hpp"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>
namespace radar::acq {
@@ -26,28 +25,6 @@ constexpr std::uint64_t kOpenRetryLogEvery = 30U;
return stop_requested.load(std::memory_order_relaxed);
}
void sleep_if_needed_ms(std::uint32_t delay_ms) {
if (delay_ms == 0U) {
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
}
// Interruptible sleep: returns true if a stop was requested while waiting.
[[nodiscard]] auto interruptible_sleep_ms(std::uint32_t delay_ms, const std::atomic<bool>& stop_requested) -> bool {
constexpr std::uint32_t kPollMs = 50U;
std::uint32_t waited = 0U;
while (waited < delay_ms) {
if (should_stop(stop_requested)) {
return true;
}
const std::uint32_t chunk = std::min(kPollMs, delay_ms - waited);
std::this_thread::sleep_for(std::chrono::milliseconds(chunk));
waited += chunk;
}
return should_stop(stop_requested);
}
void validate_sweep(const drivers::SweepTrace& sweep) {
if (sweep.frequency_hz.size() != sweep.s11.size() || sweep.frequency_hz.size() != sweep.s21.size()) {
throw std::runtime_error("Radar driver returned inconsistent sweep vectors");
@@ -66,30 +43,14 @@ class DriverLifecycleGuard {
output_switch_driver_(output_switch_driver) {}
~DriverLifecycleGuard() noexcept {
if (!active_) {
return;
}
// Shutdown should never hide the primary error path.
try {
output_switch_driver_.close();
} catch (...) {
}
try {
input_switch_driver_.close();
} catch (...) {
}
try {
radar_driver_.close();
} catch (...) {
}
close_all();
}
void open_all() {
active_ = true; // A later open failure still owns partially opened devices.
radar_driver_.open();
input_switch_driver_.open();
output_switch_driver_.open();
active_ = true;
}
// Open all devices, retrying forever with capped exponential backoff until success or
@@ -118,7 +79,7 @@ class DriverLifecycleGuard {
<< "); retrying up to every " << (kOpenRetryMaxMs / 1'000U)
<< "s until present: " << exc.what() << '\n';
}
if (interruptible_sleep_ms(delay_ms, stop_requested)) {
if (config::wait_or_stop(delay_ms, stop_requested)) {
return false;
}
delay_ms = std::min(delay_ms * 2U, kOpenRetryMaxMs);
@@ -127,14 +88,23 @@ class DriverLifecycleGuard {
return false;
}
void close_all() {
void close_all() noexcept {
if (!active_) {
return;
}
output_switch_driver_.close();
input_switch_driver_.close();
radar_driver_.close();
active_ = false;
const auto close = [](auto& driver) {
try {
driver.close();
} catch (const std::exception& error) {
std::cerr << "sweep_orchestrator: driver cleanup failed: " << error.what() << '\n';
} catch (...) {
std::cerr << "sweep_orchestrator: driver cleanup failed with an unknown error\n";
}
};
close(output_switch_driver_);
close(input_switch_driver_);
close(radar_driver_);
}
private:
@@ -238,7 +208,7 @@ void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
if (!config_.runtime.continuous) {
break;
}
sleep_if_needed_ms(config_.runtime.idle_sleep_ms);
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
continue;
}
@@ -289,11 +259,11 @@ auto SweepOrchestrator::acquire_one_collection(
// 1) switch output path, 2) switch input path, 3) optional settle.
output_switch_driver_.switch_to(combo.output_pos);
input_switch_driver_.switch_to(combo.input_pos);
sleep_if_needed_ms(config_.runtime.settling_ms);
if (config::wait_or_stop(config_.runtime.settling_ms, stop_requested)) {
interrupted = true;
break;
}
// Production drivers ignore this; mock drivers use it to give every
// (input, output) pair its own synthetic response.
radar_driver_.set_active_combo(combo);
// Stamp around the sweep only: the switch drive and settling above belong to
// neither the previous combo nor this one, so excluding them keeps the window
// an honest "when was this combo actually measured".
@@ -0,0 +1,18 @@
"""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,398 @@
#include "capture_file_writer.h"
#include "channel_bundle_support.hpp"
#include "locator/tcp_server.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>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <array>
#include <atomic>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <random>
#include <stdexcept>
#include <thread>
#include <nlohmann/json.hpp>
namespace {
using namespace radar;
using Json = nlohmann::json;
void require(bool condition, const char* message) {
if (!condition) {
throw std::runtime_error(message);
}
}
template <typename Callable>
void expect_error(Callable operation) {
try {
operation();
} catch (const std::bad_alloc&) {
throw; // A malformed count must be rejected before allocating.
} catch (const std::exception&) {
return;
}
throw std::runtime_error("Expected operation to reject malformed input");
}
struct Workspace {
std::filesystem::path path;
Workspace() {
char pattern[] = "/tmp/radar-native-XXXXXX";
const auto* created = ::mkdtemp(pattern);
require(created != nullptr, "mkdtemp failed");
path = created;
}
~Workspace() { std::filesystem::remove_all(path); }
};
struct RingName {
std::string name = "/radar_native_test_" + std::to_string(::getpid());
RingName() { ipc::ShmRing::unlink_ring(name); }
~RingName() { ipc::ShmRing::unlink_ring(name); }
};
void write_u32(std::vector<std::uint8_t>& bytes, std::size_t offset, std::uint32_t value) {
require(offset + sizeof(value) <= bytes.size(), "invalid test offset");
std::memcpy(bytes.data() + offset, &value, sizeof(value));
}
void test_wire_decoders() {
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}},
.capture_start_ns = 25, .capture_end_ns = 39}}};
ipc::ResultCollection results{.collection_id = 7, .processing_duration_ns = 17,
.collection_payloads = {
{.kind = ipc::ResultKind::TraceComplex, .frequency_hz = {1}, .trace = {{2, 3}}},
{.kind = ipc::ResultKind::ScalarF32, .scalar_value = 4},
{.kind = ipc::ResultKind::ImageF32, .image_x_axis = {1, 2},
.image_y_axis = {3}, .image_values = {4, 5}},
{.kind = ipc::ResultKind::TableF32, .table_columns = 2, .table_values = {6, 7}},
{.kind = ipc::ResultKind::ImageF32, .image_y_axis = {1}},
{.kind = ipc::ResultKind::TableF32, .table_columns = 3}},
.blocks = {{.combo = {1, 2}, .payloads = {{.kind = ipc::ResultKind::ScalarF32}}}}};
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);
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); });
}
// 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;
for (unsigned edit = 0; edit < 1U + random() % 5U; ++edit) {
bytes[random() % bytes.size()] = static_cast<std::uint8_t>(random());
}
if (iteration % 4U == 0U) {
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); }
} catch (const std::bad_alloc&) { throw; }
catch (const std::runtime_error&) {}
}
}
void test_configuration(const std::string& seed_path, const Workspace& workspace) {
std::ifstream input(seed_path);
const auto seed = Json::parse(input);
const auto config_path = workspace.path / "config.json";
const auto save = [&](const Json& config) { std::ofstream(config_path) << config; };
save(seed);
const auto config = 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},
{"/rings/raw/slot_size_bytes", 65}, {"/gpr/rx_geometry/0/x_m", 1e100}}}) {
auto invalid = seed;
invalid[Json::json_pointer(pointer)] = value;
save(invalid);
expect_error([&] { (void)config::load_run_config(config_path.string()); });
}
const auto live_path = workspace.path / "live.json";
std::ofstream(live_path) << Json{{"bscan_gain", 2}, {"settings_revision", 11}};
processing::ProcessingLiveConfigLoader loader(live_path.string(), config);
require(loader.refresh_if_needed().bscan_gain == 2, "valid live config did not load");
const auto revision = loader.revision();
for (const auto& invalid : {Json{{"bscan_gain", 1e100}},
Json{{"gpr_input_positions", {0.0000001}}}, Json{{"bscan_gain", true}}}) {
std::ofstream(live_path) << invalid;
std::this_thread::sleep_for(std::chrono::milliseconds(110));
const auto current = loader.refresh_if_needed();
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() {
RingName name;
expect_error([&] { (void)ipc::ShmRing::open_or_create(name.name, 2, 65); });
expect_error([&] { (void)ipc::ShmRing::open_or_create(name.name, 0xffffffffU, 0xfffffff8U); });
auto ring = ipc::ShmRing::open_or_create(name.name, 2, 4096);
const auto child = ::fork();
require(child >= 0, "fork failed");
if (child == 0) {
std::array<std::uint64_t, 512> words{};
for (std::uint64_t sequence = 1; sequence <= 20'000U; ++sequence) {
words.fill(sequence);
if (!ring.push({reinterpret_cast<const std::uint8_t*>(words.data()), sizeof(words)})) {
::_exit(2);
}
}
::_exit(0);
}
std::uint64_t previous = 0;
std::vector<std::uint8_t> payload;
int status = 0;
bool finished = false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
try {
while (true) {
if (ring.pop(payload)) {
require(payload.size() == 4096, "stress payload changed size");
std::uint64_t sequence = 0;
std::memcpy(&sequence, payload.data(), sizeof(sequence));
require(sequence > previous, "stress ring yielded duplicate or older data");
for (std::size_t offset = 0; offset < payload.size(); offset += sizeof(sequence)) {
std::uint64_t word = 0;
std::memcpy(&word, payload.data() + offset, sizeof(word));
require(word == sequence, "stress ring accepted a torn payload");
}
previous = sequence;
} else if (finished) { break; }
finished = finished || ::waitpid(child, &status, WNOHANG) == child;
require(std::chrono::steady_clock::now() < deadline, "ring stress timed out");
}
require(WIFEXITED(status) && WEXITSTATUS(status) == 0 && previous != 0,
"ring stress writer failed");
} catch (...) {
::kill(child, SIGKILL);
if (!finished) { (void)::waitpid(child, &status, 0); }
throw;
}
}
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_interruptible_wait() {
std::atomic<bool> stop{false};
std::jthread request_stop([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
stop.store(true);
});
const auto start = std::chrono::steady_clock::now();
require(config::wait_or_stop(0xffffffffU, stop), "large wait ignored stop");
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(1),
"large wait delayed shutdown");
}
void test_csv_failures(const Workspace& workspace) {
const auto csv = workspace.path / "capture.csv";
const auto spool = workspace.path / "capture.capture_spool.bin";
CaptureFileWriter writer(csv.string(), (workspace.path / "capture.svg").string(), "", "");
writer.initialize_csv(2, false);
CapturePacket packet{.ch1 = {1, 2}, .ch2 = {3, 4}, .di1 = {}};
auto invalid_packet = packet;
invalid_packet.ch2.pop_back();
expect_error([&] { writer.append_csv_packet(invalid_packet); });
require(std::filesystem::file_size(spool) == 16, "invalid packet partially changed the spool");
writer.append_csv_packet(packet);
std::filesystem::remove(csv);
std::filesystem::create_symlink("/dev/full", csv);
expect_error([&] { writer.write({packet}, 1000, 10); });
require(std::filesystem::file_size(spool) > 16, "failed CSV write deleted the recovery spool");
std::filesystem::remove(csv);
writer.write({packet}, 1000, 10);
require(!std::filesystem::exists(spool), "successful CSV export retained the spool");
std::ifstream csv_input(csv);
const std::string csv_text((std::istreambuf_iterator<char>(csv_input)), {});
require(csv_text.find("1.000000000,3.000000000") != std::string::npos,
"CSV retry lost channel samples");
writer.initialize_csv(2, false);
std::ofstream(spool, std::ios::binary | std::ios::app).put('x');
expect_error([&] { writer.write({}, 1000, 10); });
require(std::filesystem::exists(spool), "truncated packet header deleted the spool");
writer.initialize_csv(2, false);
const std::array<std::uint64_t, 4> corrupt_packet{0, 2, 0xffffffffffffffffULL, 0};
std::ofstream(spool, std::ios::binary | std::ios::app).write(
reinterpret_cast<const char*>(corrupt_packet.data()), sizeof(corrupt_packet));
expect_error([&] { writer.write({}, 1000, 10); });
require(std::filesystem::exists(spool), "oversized packet count deleted the spool");
}
void test_asset_reload_preserves_data(const Workspace& workspace) {
const auto save = [&](const char* name, float sample) {
const auto path = workspace.path / name;
const auto bytes = ipc::serialize_raw_collection({.traces = {
{.frequency_hz = {1}, .s11 = {{sample, 0}}, .s21 = {{sample, 0}}}}});
std::ofstream(path, std::ios::binary).write(
reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return path.string();
};
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);
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");
}
void test_locator_packet_errors() {
int sockets[2]{};
require(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0, "socketpair failed");
std::atomic<double> speed{0};
std::atomic<std::int64_t> at_ns{0};
locator::ClientSession client(sockets[0], "regression", 2, 1024);
client.start(speed, at_ns);
const auto send_json = [&](const std::string& json) {
std::vector<std::uint8_t> bytes(8 + json.size());
write_u32(bytes, 4, static_cast<std::uint32_t>(json.size()));
std::memcpy(bytes.data() + 8, json.data(), json.size());
const auto sent = ::send(sockets[1], bytes.data(), bytes.size(), MSG_NOSIGNAL);
if (sent != static_cast<ssize_t>(bytes.size())) {
throw std::runtime_error(std::string("send test packet failed: ") + std::strerror(errno));
}
};
send_json("{\"vlc\":1e400}");
send_json("{\"vlc\":1e308}");
std::this_thread::sleep_for(std::chrono::milliseconds(50));
require(at_ns.load() == 0 && speed.load() == 0, "invalid socket speed reached the processor");
send_json("{\"vlc\":3.5}");
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (at_ns.load() == 0 && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
require(speed.load() == 3.5 && at_ns.load() > 0, "locator stopped after oversized JSON number");
client.request_stop();
client.join();
::close(sockets[1]);
require(client.has_exited(), "locator session did not finish both loops");
locator::LocatorServerConfig config{.host = "127.0.0.1", .port = 0};
locator::TcpServer server(config);
for (unsigned iteration = 0; iteration < 30; ++iteration) { server.start(); server.stop(); }
}
void test_unread_tty_shutdown(const Workspace& workspace) {
TtyProtocolWriter writer((workspace.path / "tty").string(), 160'000);
const std::vector<std::uint16_t> words(80'000, 42);
writer.enqueue_encoded_frames(words.data(), words.size() / 4U);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
const auto start = std::chrono::steady_clock::now();
writer.shutdown();
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(1),
"unread tty blocked shutdown");
writer.shutdown();
}
} // namespace
int main(int argc, char** argv) {
try {
require(argc == 2, "Pass the simulator config fixture path");
Workspace workspace;
test_wire_decoders();
test_configuration(argv[1], workspace);
test_asset_reload_preserves_data(workspace);
test_ring();
test_partial_driver_open();
test_interruptible_wait();
test_csv_failures(workspace);
test_locator_packet_errors();
test_unread_tty_shutdown(workspace);
std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n";
} catch (const std::exception& error) {
std::cerr << "native regression failed: " << error.what() << '\n';
return 1;
}
}
@@ -0,0 +1,90 @@
#include "sar3d_processor.hpp"
#include "../processing/processors/src/sar3d_gpu.hpp"
#include <iostream>
#include <nlohmann/json.hpp>
using Json = nlohmann::json;
using namespace radar;
namespace {
auto complex_samples(const Json& samples) -> std::vector<std::complex<float>> {
std::vector<std::complex<float>> values;
for (const auto& sample : samples) {
values.emplace_back(sample.at(0).get<float>(), sample.at(1).get<float>());
}
return values;
}
auto collection(const Json& source) -> ipc::PreprocessedCollection {
ipc::PreprocessedCollection result;
result.collection_id = source.at("id");
result.monotonic_ns = source.at("timestamp");
for (const auto& entry : source.at("traces")) {
ipc::SweepTraceBlock trace;
trace.combo = {entry.at("input"), entry.at("output")};
trace.capture_start_ns = entry.at("start");
trace.capture_end_ns = entry.at("end");
trace.frequency_hz = entry.at("frequency").get<std::vector<float>>();
for (const auto& sample : complex_samples(entry.at("s21"))) {
trace.s21.push_back({sample.real(), sample.imag()});
}
result.traces.push_back(std::move(trace));
}
return result;
}
}
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()});
}
}
std::cout << output << '\n';
} catch (const std::exception& error) {
std::cout << Json{{"error", error.what()}} << '\n';
return 1;
}
}
@@ -0,0 +1,136 @@
// 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;
}
}
@@ -0,0 +1,85 @@
// 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;
}
}
@@ -0,0 +1,297 @@
"""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()