Refactor radar system
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user