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
@@ -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()