added timing

This commit is contained in:
Ayzen
2026-05-26 15:08:56 +03:00
parent 5b480f1b55
commit 83a934f251
42 changed files with 1680 additions and 740 deletions
+8 -2
View File
@@ -21,7 +21,8 @@ INCLUDES := \
-Idata_acq_and_processing/preprocessing/reference_master/include \
-Idata_acq_and_processing/preprocessing/data_preprocessor/include \
-Idata_acq_and_processing/processing/processors/include \
-Idata_acq_and_processing/processing/data_processor/include
-Idata_acq_and_processing/processing/data_processor/include \
-Idata_acq_and_processing/processing/locator/include
COMMON_SOURCES := \
data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp \
@@ -46,13 +47,18 @@ PREPROC_SOURCES := \
data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp \
data_acq_and_processing/preprocessing/data_preprocessor/src/main.cpp
LOCATOR_SOURCES := \
data_acq_and_processing/processing/locator/src/payload_builder.cpp \
data_acq_and_processing/processing/locator/src/tcp_server.cpp
PROCESSOR_SOURCES := \
data_acq_and_processing/processing/processors/src/bscan_processor.cpp \
data_acq_and_processing/processing/processors/src/gpr_processor.cpp \
data_acq_and_processing/processing/processors/src/passthrough_processor.cpp \
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
data_acq_and_processing/processing/data_processor/src/data_processor.cpp \
data_acq_and_processing/processing/data_processor/src/main.cpp
data_acq_and_processing/processing/data_processor/src/main.cpp \
$(LOCATOR_SOURCES)
SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o))
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
@@ -4,6 +4,7 @@
#include <string>
#include <vector>
#include "locator/locator_config.hpp"
#include "shared_types.hpp"
namespace radar::config {
@@ -148,6 +149,7 @@ struct RunConfig {
RuntimeConfig runtime{};
PreprocessConfig preprocess{};
GprConfig gpr{};
radar::locator::LocatorServerConfig locator_server{};
std::vector<radar::ipc::ComboKey> run_combos{};
};
@@ -194,6 +194,33 @@ using Json = nlohmann::json;
return notch;
}
[[nodiscard]] auto parse_locator_server(const Json& object) -> radar::locator::LocatorServerConfig {
radar::locator::LocatorServerConfig locator{};
locator.enabled = optional_bool(object, "enabled", locator.enabled);
locator.host = optional_string(object, "host", locator.host);
locator.device_id = optional_u32(object, "device_id", locator.device_id);
locator.protocol_version = optional_u32(object, "protocol_version", locator.protocol_version);
locator.max_payload_bytes = optional_u32(object, "max_payload_bytes", locator.max_payload_bytes);
locator.client_queue_size = optional_u32(object, "client_queue_size", locator.client_queue_size);
if (const auto* port_value = optional_field(object, "port"); port_value != nullptr) {
const auto port_u32 = number_to_u32(as_number(*port_value, "run.locator_server.port"),
"run.locator_server.port");
if (port_u32 == 0U || port_u32 > 0xFFFFU) {
throw std::runtime_error("run.locator_server.port must be in [1, 65535]");
}
locator.port = static_cast<std::uint16_t>(port_u32);
}
if (locator.client_queue_size == 0U) {
throw std::runtime_error("run.locator_server.client_queue_size must be > 0");
}
if (locator.max_payload_bytes == 0U) {
throw std::runtime_error("run.locator_server.max_payload_bytes must be > 0");
}
return locator;
}
[[nodiscard]] auto parse_driver_mode(const std::string& value) -> DriverMode {
if (value == "mock") {
return DriverMode::Mock;
@@ -479,6 +506,12 @@ auto load_run_config(const std::string& path) -> RunConfig {
"python_app/runtime/processing_live.json"
);
if (const auto* locator_value = optional_field(*run_obj, "locator_server");
locator_value != nullptr) {
config.locator_server =
parse_locator_server(*as_object(*locator_value, "run.locator_server"));
}
const auto* combos = as_array(required_field(*run_obj, "combos"), "run.combos");
if (combos->empty()) {
throw std::runtime_error("run.combos must not be empty");
@@ -83,6 +83,11 @@ struct ResultBlock {
struct ResultCollection {
std::uint64_t collection_id = 0;
std::uint64_t monotonic_ns = 0;
// Wall-clock duration of `process_collection()` for this collection,
// measured on the data_processor side with a monotonic clock. Used by the
// Python pipeline to report processing-time metrics. Zero is a valid
// "unmeasured" sentinel for legacy producers.
std::uint64_t processing_duration_ns = 0;
std::vector<ResultPayload> collection_payloads{};
std::vector<ResultBlock> blocks{};
};
@@ -14,7 +14,7 @@ namespace {
constexpr std::uint32_t kRawCollectionMagic = 0x32574152U; // RAW2
constexpr std::uint32_t kPreprocessedCollectionMagic = 0x32525050U; // PRP2
constexpr std::uint32_t kResultCollectionMagic = 0x314C5352U; // RSL1
constexpr std::uint32_t kResultCollectionMagic = 0x324C5352U; // RSL2
template <typename T>
concept TriviallySerializable = std::is_trivially_copyable_v<T>;
@@ -408,6 +408,7 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
writer.write(kResultCollectionMagic);
writer.write(collection.collection_id);
writer.write(collection.monotonic_ns);
writer.write(collection.processing_duration_ns);
writer.write(checked_count_to_u32(collection.collection_payloads.size(), "Collection payload count"));
writer.write(checked_count_to_u32(collection.blocks.size(), "Result block count"));
@@ -432,6 +433,7 @@ auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> Resul
ResultCollection collection{};
collection.collection_id = reader.read<std::uint64_t>();
collection.monotonic_ns = reader.read<std::uint64_t>();
collection.processing_duration_ns = reader.read<std::uint64_t>();
const auto collection_payload_count = reader.read<std::uint32_t>();
const auto block_count = reader.read<std::uint32_t>();
@@ -7,6 +7,7 @@
#include <unordered_map>
#include <vector>
#include "locator/tcp_server.hpp"
#include "processor_interface.hpp"
#include "processing_live_config.hpp"
#include "run_config.hpp"
@@ -22,7 +23,8 @@ class DataProcessor {
const config::RunConfig& config,
ipc::ShmRing& preprocessed_ring,
ipc::ShmRing& results_ring,
ProcessorRegistry processors
ProcessorRegistry processors,
radar::locator::TcpServer* locator_server = nullptr
);
void run(const std::atomic<bool>& stop_requested);
@@ -38,12 +40,26 @@ class DataProcessor {
[[nodiscard]] auto resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
[[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool;
// Merge live config with the latest socket-supplied speed (if any and if
// not suppressed by `ignore_socket_speed`). The returned config is what
// actually drives processing for this tick.
[[nodiscard]] auto resolve_effective_live_config(const ProcessingLiveConfig& live_config) const
-> ProcessingLiveConfig;
// Convert one processed collection into a locator filter spec, derived
// from the live config and current processor mode.
[[nodiscard]] auto build_locator_filter(const ProcessingLiveConfig& live_config) const
-> radar::locator::FilterParams;
void publish_locator(const ipc::ResultCollection& collection, const ProcessingLiveConfig& live_config);
const config::RunConfig& config_;
ipc::ShmRing& preprocessed_ring_;
ipc::ShmRing& results_ring_;
ProcessorRegistry processors_{};
std::string default_processor_mode_{};
ProcessingLiveConfigLoader live_config_loader_;
radar::locator::TcpServer* locator_server_ = nullptr;
};
[[nodiscard]] auto create_default_processors() -> ProcessorRegistry;
@@ -52,6 +52,17 @@ struct ProcessingLiveConfig {
// BP image is computed in the y=imaging_plane_y_m slice of the 3D grid.
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
float gpr_imaging_plane_y_m = 0.0F;
// Locator filter parameters. Mode-dependent threshold (legacy_gpr uses
// `legacy_gpr_min_visible_pair_count`, everything else uses
// `gpr_min_visible_score`). Draw limits apply only to non-legacy modes.
float gpr_min_visible_score = 0.0F;
float legacy_gpr_min_visible_pair_count = 0.0F;
std::uint32_t gpr_max_detected_objects_to_draw = 0;
std::uint32_t gpr_draw_top_m_objects = 0;
// When true, the data_processor ignores socket-supplied `vlc` updates and
// keeps using `gpr_speed_m_s` from this file. Mirrored from the GUI's
// "ignore socket speed" checkbox.
bool ignore_socket_speed = false;
bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None;
@@ -37,14 +37,16 @@ DataProcessor::DataProcessor(
const config::RunConfig& config,
ipc::ShmRing& preprocessed_ring,
ipc::ShmRing& results_ring,
ProcessorRegistry processors
ProcessorRegistry processors,
radar::locator::TcpServer* locator_server
)
: config_(config),
preprocessed_ring_(preprocessed_ring),
results_ring_(results_ring),
processors_(std::move(processors)),
default_processor_mode_(kDefaultProcessorMode),
live_config_loader_(config.runtime.processing_live_config_path) {
live_config_loader_(config.runtime.processing_live_config_path),
locator_server_(locator_server) {
if (processors_.empty()) {
throw std::runtime_error("DataProcessor requires at least one processor");
}
@@ -61,7 +63,8 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::uint64_t last_applied_history_command_seq = 0;
while (!stop_requested.load(std::memory_order_relaxed)) {
const auto live_config = live_config_loader_.refresh_if_needed();
const auto live_config_raw = live_config_loader_.refresh_if_needed();
const auto live_config = resolve_effective_live_config(live_config_raw);
const auto live_revision = live_config_loader_.revision();
auto& processor = resolve_processor(live_config);
@@ -89,6 +92,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config
);
publish_result_collection(replay_result, results_ring_);
publish_locator(replay_result, live_config);
}
} else if (!preprocessed_history.empty()) {
const auto replay_result = process_collection(
@@ -98,6 +102,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config
);
publish_result_collection(replay_result, results_ring_);
publish_locator(replay_result, live_config);
}
last_replayed_revision = live_revision;
}
@@ -116,6 +121,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config
);
publish_result_collection(result_collection, results_ring_);
publish_locator(result_collection, live_config);
continue;
}
@@ -129,7 +135,13 @@ auto DataProcessor::process_collection(
ProcessorInterface& processor,
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
return processor.process_collection(config_, preprocessed, previous_collections, live_config);
const auto started_at = std::chrono::steady_clock::now();
auto result = processor.process_collection(config_, preprocessed, previous_collections, live_config);
const auto finished_at = std::chrono::steady_clock::now();
result.processing_duration_ns = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(finished_at - started_at).count()
);
return result;
}
auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface& {
@@ -151,6 +163,54 @@ auto DataProcessor::should_replay_entire_history(const ProcessingLiveConfig& liv
return requested_mode == "bscan";
}
auto DataProcessor::resolve_effective_live_config(const ProcessingLiveConfig& live_config) const
-> ProcessingLiveConfig {
if (live_config.ignore_socket_speed || locator_server_ == nullptr) {
return live_config;
}
const auto socket_speed = locator_server_->latest_socket_speed();
if (!socket_speed.has_value()) {
return live_config;
}
ProcessingLiveConfig effective = live_config;
effective.gpr_speed_m_s = static_cast<float>(*socket_speed);
return effective;
}
auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config) const
-> radar::locator::FilterParams {
const std::string requested_mode =
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
radar::locator::FilterParams filter{};
if (requested_mode == "legacy_gpr") {
filter.min_score = live_config.legacy_gpr_min_visible_pair_count;
// The GUI deliberately disables the "draw top N" capping for legacy
// GPR, so we also skip it on the wire to match observation semantics.
filter.draw_limits.reset();
} else {
filter.min_score = live_config.gpr_min_visible_score;
if (live_config.gpr_max_detected_objects_to_draw > 0U
&& live_config.gpr_draw_top_m_objects > 0U) {
filter.draw_limits = radar::locator::DrawLimits{
.max_detected_objects = live_config.gpr_max_detected_objects_to_draw,
.draw_top_objects = live_config.gpr_draw_top_m_objects,
};
}
}
return filter;
}
void DataProcessor::publish_locator(
const ipc::ResultCollection& collection,
const ProcessingLiveConfig& live_config
) {
if (locator_server_ == nullptr || !locator_server_->is_running()) {
return;
}
locator_server_->publish(collection, build_locator_filter(live_config));
}
auto create_default_processors() -> ProcessorRegistry {
ProcessorRegistry processors{};
{
@@ -2,9 +2,11 @@
#include <csignal>
#include <exception>
#include <iostream>
#include <memory>
#include <string>
#include "data_processor.hpp"
#include "locator/tcp_server.hpp"
#include "run_config.hpp"
#include "shm_ring.hpp"
@@ -21,6 +23,8 @@ void signal_handler(int /*signal*/) {
void install_signal_handlers() {
std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, signal_handler);
// Writing to a peer-closed socket would otherwise terminate the process.
std::signal(SIGPIPE, SIG_IGN);
}
[[nodiscard]] auto read_config_path(int argc, char** argv) -> std::string {
@@ -34,6 +38,21 @@ void install_signal_handlers() {
return config_path;
}
[[nodiscard]] auto start_locator_server(const radar::config::RunConfig& config)
-> std::unique_ptr<radar::locator::TcpServer> {
if (!config.locator_server.enabled) {
return nullptr;
}
auto server = std::make_unique<radar::locator::TcpServer>(config.locator_server);
try {
server->start();
} catch (const std::exception& exception) {
std::cerr << "data_processor: locator server disabled (" << exception.what() << ")\n";
return nullptr;
}
return server;
}
} // namespace
int main(int argc, char** argv) {
@@ -54,11 +73,14 @@ int main(int argc, char** argv) {
config.rings.results.slot_size_bytes
);
auto locator_server = start_locator_server(config);
radar::processing::DataProcessor processor(
config,
preprocessed_ring,
results_ring,
radar::processing::create_default_processors()
radar::processing::create_default_processors(),
locator_server.get()
);
processor.run(g_stop_requested);
return 0;
@@ -311,6 +311,32 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
config.gpr_imaging_plane_y_m = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_min_visible_score"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_min_visible_score must be number");
}
config.gpr_min_visible_score = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("legacy_gpr_min_visible_pair_count"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.legacy_gpr_min_visible_pair_count must be number");
}
config.legacy_gpr_min_visible_pair_count = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_max_detected_objects_to_draw"); found != root.end()) {
config.gpr_max_detected_objects_to_draw =
parse_u32_number(*found, "processing.gpr_max_detected_objects_to_draw");
}
if (const auto found = root.find("gpr_draw_top_m_objects"); found != root.end()) {
config.gpr_draw_top_m_objects =
parse_u32_number(*found, "processing.gpr_draw_top_m_objects");
}
if (const auto found = root.find("ignore_socket_speed"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.ignore_socket_speed must be bool");
}
config.ignore_socket_speed = found->get<bool>();
}
if (const auto found = root.find("reprocess_current_result"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.reprocess_current_result must be bool");
@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
#include <string>
namespace radar::locator {
// Static configuration for the locator TCP server, mirrored from `run.locator_server`
// in `run_config.json`. Values are validated by the config parser before reaching here.
struct LocatorServerConfig {
bool enabled = true;
std::string host = "0.0.0.0";
std::uint16_t port = 8888;
std::uint32_t device_id = 3;
std::uint32_t protocol_version = 1;
std::uint32_t max_payload_bytes = 64U * 1024U;
std::uint32_t client_queue_size = 32;
};
} // namespace radar::locator
@@ -0,0 +1,14 @@
#pragma once
namespace radar::locator {
// One outbound locator observation: object position relative to the radar in metres.
// `crs` is the cross-range (X) coordinate; `dst` is the range (Z) coordinate.
// Values are kept as float because they are quantised to two decimal places before
// being serialised on the wire.
struct Observation {
float crs = 0.0F;
float dst = 0.0F;
};
} // namespace radar::locator
@@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "locator/observation.hpp"
#include "shared_types.hpp"
namespace radar::locator {
// Inclusive bounds used to clip locator observations to a visible window.
struct VisibleBounds {
float x_min = 0.0F;
float x_max = 0.0F;
float z_min = 0.0F;
float z_max = 0.0F;
};
// Limits mirroring the GUI semantics: if the number of detected objects exceeds
// `max_detected_objects`, the result is intentionally empty (matches Python
// reference). Otherwise, at most `draw_top_objects` rows are emitted.
struct DrawLimits {
std::uint32_t max_detected_objects = 0;
std::uint32_t draw_top_objects = 0;
};
// Filter parameters for the payload builder. None of these fields couple to GUI
// state; they are resolved by the data_processor from the live processing config
// and passed in explicitly so the builder remains a pure function.
struct FilterParams {
float min_score = 0.0F;
std::optional<VisibleBounds> visible_bounds{};
std::optional<DrawLimits> draw_limits{};
};
// Extract observations from one result collection.
//
// Mirrors `python_app/orchestration/gpr_locator.py::locator_observations_from_collection`:
// * Looks for a `gpr_points` TableF32 payload first, then falls back to
// `gpr_region_centers`. Both layouts encode `[x_m, z_m, score, ...]` rows.
// * Drops rows with non-finite coordinates and those below `min_score`.
// * If `visible_bounds` are provided, drops rows outside the inclusive window.
// * If `draw_limits` are provided and the surviving count exceeds
// `max_detected_objects`, returns an empty vector (the GUI's "too many to
// trust" heuristic). Otherwise, keeps the first `draw_top_objects` rows.
//
// The function never throws; malformed payloads degrade to an empty result.
[[nodiscard]] auto observations_from_collection(
const ipc::ResultCollection& collection,
const FilterParams& filter
) -> std::vector<Observation>;
// Serialize a list of observations into the JSON payload format expected by
// locator clients: `{"ver": <n>, "tim": "HH:MM:SS.mmm", "sts": 1, "obs": [...]}`.
// Each observation contributes `{"dst": <m>, "crs": <m>}` with two-decimal
// quantisation.
[[nodiscard]] auto build_payload_json(
const std::vector<Observation>& observations,
std::uint32_t protocol_version,
std::uint32_t status = 1U
) -> std::string;
// Wrap a JSON payload string into a framed wire packet:
// `<device_id:u32 LE><payload_len:u32 LE><payload bytes...>`.
[[nodiscard]] auto encode_packet(
const std::string& payload_json,
std::uint32_t device_id
) -> std::vector<std::uint8_t>;
} // namespace radar::locator
@@ -0,0 +1,165 @@
#pragma once
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#include "locator/locator_config.hpp"
#include "locator/payload_builder.hpp"
#include "shared_types.hpp"
namespace radar::locator {
// Bounded, in-memory packet queue used by the per-client writer thread.
// Marking the queue as full closes the client (back-pressure by disconnect),
// matching the semantics of the previous Python implementation.
class ClientQueue {
public:
explicit ClientQueue(std::size_t capacity);
// Push a packet onto the queue. Returns false if the queue is full or has
// been closed; the caller is expected to disconnect the client in that case.
[[nodiscard]] auto try_push(std::vector<std::uint8_t> packet) -> bool;
// Block until a packet is available or the queue is closed.
// Returns nullopt iff the queue has been closed and is drained.
[[nodiscard]] auto wait_pop() -> std::optional<std::vector<std::uint8_t>>;
// Wake any waiter and reject further pushes. Idempotent.
void close();
[[nodiscard]] auto is_closed() const -> bool;
private:
mutable std::mutex mutex_{};
std::condition_variable not_empty_{};
std::deque<std::vector<std::uint8_t>> queue_{};
std::size_t capacity_;
bool closed_ = false;
};
// One connected locator client: owns its socket, writer thread, reader thread,
// and outbound queue. Removed from the server's roster once both threads exit.
class ClientSession {
public:
ClientSession(
int socket_fd,
std::string peer_name,
std::size_t queue_capacity,
std::uint32_t max_payload_bytes
);
~ClientSession();
ClientSession(const ClientSession&) = delete;
auto operator=(const ClientSession&) -> ClientSession& = delete;
ClientSession(ClientSession&&) = delete;
auto operator=(ClientSession&&) -> ClientSession& = delete;
// Start writer/reader threads. The shared `vlc` slot is notified whenever
// an inbound packet carrying a finite `vlc` field arrives.
void start(std::atomic<double>& shared_vlc_slot);
// Enqueue one outbound packet. Disconnects this session if the queue is
// already full or the writer has stopped.
void enqueue(std::vector<std::uint8_t> packet);
// Initiate teardown of this client (idempotent): closes the queue and
// shuts the socket so writer/reader threads can exit promptly.
void request_stop();
// Join writer/reader threads and release the socket. Must be called from a
// thread other than this session's writer or reader.
void join();
[[nodiscard]] auto has_exited() const -> bool;
[[nodiscard]] auto peer_name() const -> const std::string&;
private:
void writer_loop();
void reader_loop(std::atomic<double>& shared_vlc_slot);
int socket_fd_;
std::string peer_name_;
ClientQueue queue_;
std::uint32_t max_payload_bytes_;
std::atomic<bool> stop_requested_{false};
std::atomic<bool> exited_{false};
std::thread writer_thread_{};
std::thread reader_thread_{};
};
// Multi-client TCP locator server.
//
// Design contract:
// * Threading: one acceptor thread + two threads per connected client. The
// producer (data_processor) calls publish() synchronously; that call is
// non-blocking and never throws for typical operation.
// * Back-pressure: each client has its own bounded outbound queue. If a
// client is too slow to drain, the next publish() drops it (matches the
// prior Python service). Other clients are unaffected.
// * Latest-snapshot: the most recently published packet is cached and sent
// to every newly connected client before any new packets are forwarded.
// * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent
// and is also invoked from the destructor.
class TcpServer {
public:
explicit TcpServer(LocatorServerConfig config);
~TcpServer();
TcpServer(const TcpServer&) = delete;
auto operator=(const TcpServer&) -> TcpServer& = delete;
TcpServer(TcpServer&&) = delete;
auto operator=(TcpServer&&) -> TcpServer& = delete;
// Bind the listening socket and start the acceptor thread.
// Throws std::runtime_error on socket(), bind() or listen() failure.
void start();
// Tear down all clients and the acceptor. Idempotent.
void stop();
[[nodiscard]] auto is_running() const -> bool;
// Build and broadcast one locator packet derived from a GPR result
// collection. Never blocks for I/O. Filter parameters are supplied by the
// caller so the builder remains state-free.
void publish(const ipc::ResultCollection& collection, const FilterParams& filter);
// Most recent `vlc` value received from any connected client, or nullopt
// if no client has ever submitted one since startup. Lock-free.
[[nodiscard]] auto latest_socket_speed() const -> std::optional<double>;
private:
void acceptor_loop();
void enroll_client(std::unique_ptr<ClientSession> session);
void broadcast_packet(const std::vector<std::uint8_t>& packet);
void reap_finished_clients();
void cache_latest_packet(std::vector<std::uint8_t> packet);
[[nodiscard]] auto latest_packet_copy() const -> std::optional<std::vector<std::uint8_t>>;
LocatorServerConfig config_;
std::atomic<bool> running_{false};
int listen_fd_ = -1;
std::thread acceptor_thread_{};
mutable std::mutex clients_mutex_{};
std::vector<std::unique_ptr<ClientSession>> clients_{};
mutable std::mutex latest_packet_mutex_{};
std::optional<std::vector<std::uint8_t>> latest_packet_{};
// Sentinel of "no value yet" is NaN. Lock-free read from data_processor.
std::atomic<double> latest_socket_speed_{};
};
} // namespace radar::locator
@@ -0,0 +1,208 @@
#include "locator/payload_builder.hpp"
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <string>
#include <nlohmann/json.hpp>
namespace radar::locator {
namespace {
using Json = nlohmann::json;
constexpr const char* kGprPointsName = "gpr_points";
constexpr const char* kGprRegionCentersName = "gpr_region_centers";
constexpr std::uint32_t kMinTableColumns = 3U; // [x_m, z_m, score, ...]
// Locate the first TableF32 payload matching one of the GPR object names.
// Returns nullptr if no usable payload is present in the collection.
[[nodiscard]] auto find_object_table(const ipc::ResultCollection& collection)
-> const ipc::ResultPayload* {
const ipc::ResultPayload* fallback = nullptr;
for (const auto& payload : collection.collection_payloads) {
if (payload.kind != ipc::ResultKind::TableF32) {
continue;
}
if (payload.table_columns < kMinTableColumns) {
continue;
}
if (payload.processing_name == kGprPointsName) {
return &payload;
}
if (fallback == nullptr && payload.processing_name == kGprRegionCentersName) {
fallback = &payload;
}
}
return fallback;
}
// Quantise to two decimal places. Equivalent to Python's `round(value, 2)`
// but explicit so behaviour does not silently depend on the local C library.
[[nodiscard]] auto quantise_to_centimetres(float value) -> float {
return std::round(value * 100.0F) / 100.0F;
}
[[nodiscard]] auto passes_basic_filter(
float x_m,
float z_m,
float score,
float min_score
) -> bool {
if (!std::isfinite(x_m) || !std::isfinite(z_m) || !std::isfinite(score)) {
return false;
}
return score >= min_score;
}
[[nodiscard]] auto passes_visible_bounds(
float x_m,
float z_m,
const VisibleBounds& bounds
) -> bool {
return x_m >= bounds.x_min
&& x_m <= bounds.x_max
&& z_m >= bounds.z_min
&& z_m <= bounds.z_max;
}
// Render the current wall-clock time as "HH:MM:SS.mmm". Uses localtime to match
// the Python reference behaviour (which calls datetime.now() with no tz info).
[[nodiscard]] auto format_timestamp_now() -> std::string {
using Clock = std::chrono::system_clock;
const auto now = Clock::now();
const auto seconds = std::chrono::time_point_cast<std::chrono::seconds>(now);
const auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(
now - seconds
).count();
const std::time_t epoch_seconds = Clock::to_time_t(seconds);
std::tm broken_down{};
#if defined(_WIN32)
localtime_s(&broken_down, &epoch_seconds);
#else
localtime_r(&epoch_seconds, &broken_down);
#endif
std::ostringstream stream;
stream << std::put_time(&broken_down, "%H:%M:%S")
<< '.' << std::setw(3) << std::setfill('0') << millis;
return stream.str();
}
void append_u32_little_endian(std::vector<std::uint8_t>& buffer, std::uint32_t value) {
buffer.push_back(static_cast<std::uint8_t>(value & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 8U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 16U) & 0xFFU));
buffer.push_back(static_cast<std::uint8_t>((value >> 24U) & 0xFFU));
}
} // namespace
auto observations_from_collection(
const ipc::ResultCollection& collection,
const FilterParams& filter
) -> std::vector<Observation> {
const auto* payload = find_object_table(collection);
if (payload == nullptr) {
return {};
}
const std::uint32_t columns = payload->table_columns;
if (columns == 0U) {
return {};
}
const std::size_t row_count = payload->table_values.size() / columns;
if (row_count == 0U) {
return {};
}
std::vector<Observation> visible;
visible.reserve(row_count);
for (std::size_t row = 0; row < row_count; ++row) {
const std::size_t base = row * static_cast<std::size_t>(columns);
const float x_m = payload->table_values[base + 0U];
const float z_m = payload->table_values[base + 1U];
const float score = payload->table_values[base + 2U];
if (!passes_basic_filter(x_m, z_m, score, filter.min_score)) {
continue;
}
if (filter.visible_bounds.has_value()
&& !passes_visible_bounds(x_m, z_m, *filter.visible_bounds)) {
continue;
}
visible.push_back({.crs = x_m, .dst = z_m});
}
if (!filter.draw_limits.has_value()) {
std::vector<Observation> quantised;
quantised.reserve(visible.size());
for (const auto& observation : visible) {
quantised.push_back({
.crs = quantise_to_centimetres(observation.crs),
.dst = quantise_to_centimetres(observation.dst),
});
}
return quantised;
}
const auto& limits = *filter.draw_limits;
if (visible.size() > limits.max_detected_objects) {
return {};
}
const std::size_t kept = std::min<std::size_t>(visible.size(), limits.draw_top_objects);
std::vector<Observation> result;
result.reserve(kept);
for (std::size_t index = 0; index < kept; ++index) {
result.push_back({
.crs = quantise_to_centimetres(visible[index].crs),
.dst = quantise_to_centimetres(visible[index].dst),
});
}
return result;
}
auto build_payload_json(
const std::vector<Observation>& observations,
std::uint32_t protocol_version,
std::uint32_t status
) -> std::string {
Json obs_array = Json::array();
for (const auto& observation : observations) {
obs_array.push_back({
{"dst", observation.dst},
{"crs", observation.crs},
});
}
const Json root{
{"ver", protocol_version},
{"tim", format_timestamp_now()},
{"sts", status},
{"obs", std::move(obs_array)},
};
return root.dump();
}
auto encode_packet(
const std::string& payload_json,
std::uint32_t device_id
) -> std::vector<std::uint8_t> {
const auto payload_size = static_cast<std::uint32_t>(payload_json.size());
std::vector<std::uint8_t> packet;
packet.reserve(static_cast<std::size_t>(8U) + payload_json.size());
append_u32_little_endian(packet, device_id);
append_u32_little_endian(packet, payload_size);
const auto* bytes = reinterpret_cast<const std::uint8_t*>(payload_json.data());
packet.insert(packet.end(), bytes, bytes + payload_json.size());
return packet;
}
} // namespace radar::locator
@@ -0,0 +1,496 @@
#include "locator/tcp_server.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <limits>
#include <mutex>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <nlohmann/json.hpp>
namespace radar::locator {
namespace {
using Json = nlohmann::json;
constexpr std::size_t kPacketHeaderSize = 8U; // device_id u32 LE + payload_len u32 LE.
// Best-effort full-write helper: loops over write() until everything is sent
// or an error occurs. Returns false on socket error or peer disconnect.
[[nodiscard]] auto write_all(int socket_fd, const std::uint8_t* data, std::size_t size) -> bool {
std::size_t written = 0;
while (written < size) {
const auto chunk = ::send(
socket_fd,
data + written,
size - written,
MSG_NOSIGNAL
);
if (chunk < 0) {
if (errno == EINTR) {
continue;
}
return false;
}
if (chunk == 0) {
return false;
}
written += static_cast<std::size_t>(chunk);
}
return true;
}
// Best-effort exact-read helper. Returns false if the peer closed the socket
// or an unrecoverable error occurred before all bytes were read.
[[nodiscard]] auto read_exact(int socket_fd, std::uint8_t* data, std::size_t size) -> bool {
std::size_t consumed = 0;
while (consumed < size) {
const auto chunk = ::recv(socket_fd, data + consumed, size - consumed, 0);
if (chunk < 0) {
if (errno == EINTR) {
continue;
}
return false;
}
if (chunk == 0) {
return false;
}
consumed += static_cast<std::size_t>(chunk);
}
return true;
}
[[nodiscard]] auto decode_u32_little_endian(const std::uint8_t* bytes) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[0])
| (static_cast<std::uint32_t>(bytes[1]) << 8U)
| (static_cast<std::uint32_t>(bytes[2]) << 16U)
| (static_cast<std::uint32_t>(bytes[3]) << 24U);
}
[[nodiscard]] auto format_peer(const sockaddr_storage& addr) -> std::string {
std::array<char, INET6_ADDRSTRLEN> host_buffer{};
std::array<char, NI_MAXSERV> port_buffer{};
const auto err = ::getnameinfo(
reinterpret_cast<const sockaddr*>(&addr),
sizeof(addr),
host_buffer.data(),
host_buffer.size(),
port_buffer.data(),
port_buffer.size(),
NI_NUMERICHOST | NI_NUMERICSERV
);
if (err != 0) {
return "unknown";
}
return std::string(host_buffer.data()) + ':' + port_buffer.data();
}
void apply_socket_keepalive(int socket_fd) {
int yes = 1;
(void)::setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes));
(void)::setsockopt(socket_fd, SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes));
}
void shutdown_and_close(int& socket_fd) {
if (socket_fd < 0) {
return;
}
(void)::shutdown(socket_fd, SHUT_RDWR);
(void)::close(socket_fd);
socket_fd = -1;
}
void log_warning(const std::string& message) {
std::cerr << "locator: " << message << '\n';
}
} // namespace
// ----- ClientQueue ----------------------------------------------------------
ClientQueue::ClientQueue(std::size_t capacity) : capacity_(std::max<std::size_t>(1U, capacity)) {}
auto ClientQueue::try_push(std::vector<std::uint8_t> packet) -> bool {
{
std::lock_guard<std::mutex> guard(mutex_);
if (closed_ || queue_.size() >= capacity_) {
return false;
}
queue_.push_back(std::move(packet));
}
not_empty_.notify_one();
return true;
}
auto ClientQueue::wait_pop() -> std::optional<std::vector<std::uint8_t>> {
std::unique_lock<std::mutex> guard(mutex_);
not_empty_.wait(guard, [this]() { return closed_ || !queue_.empty(); });
if (queue_.empty()) {
return std::nullopt;
}
auto packet = std::move(queue_.front());
queue_.pop_front();
return packet;
}
void ClientQueue::close() {
{
std::lock_guard<std::mutex> guard(mutex_);
if (closed_) {
return;
}
closed_ = true;
}
not_empty_.notify_all();
}
auto ClientQueue::is_closed() const -> bool {
std::lock_guard<std::mutex> guard(mutex_);
return closed_;
}
// ----- ClientSession --------------------------------------------------------
ClientSession::ClientSession(
int socket_fd,
std::string peer_name,
std::size_t queue_capacity,
std::uint32_t max_payload_bytes
)
: socket_fd_(socket_fd),
peer_name_(std::move(peer_name)),
queue_(queue_capacity),
max_payload_bytes_(max_payload_bytes) {}
ClientSession::~ClientSession() {
request_stop();
join();
shutdown_and_close(socket_fd_);
}
void ClientSession::start(std::atomic<double>& shared_vlc_slot) {
writer_thread_ = std::thread([this]() { writer_loop(); });
reader_thread_ = std::thread([this, &shared_vlc_slot]() { reader_loop(shared_vlc_slot); });
}
void ClientSession::enqueue(std::vector<std::uint8_t> packet) {
if (stop_requested_.load(std::memory_order_acquire)) {
return;
}
if (!queue_.try_push(std::move(packet))) {
log_warning("disconnecting client " + peer_name_ + " after outbound queue overflow");
request_stop();
}
}
void ClientSession::request_stop() {
if (stop_requested_.exchange(true, std::memory_order_acq_rel)) {
return;
}
queue_.close();
// Wake any blocking recv() in the reader thread.
if (socket_fd_ >= 0) {
(void)::shutdown(socket_fd_, SHUT_RDWR);
}
}
void ClientSession::join() {
if (writer_thread_.joinable()) {
writer_thread_.join();
}
if (reader_thread_.joinable()) {
reader_thread_.join();
}
}
auto ClientSession::has_exited() const -> bool {
return exited_.load(std::memory_order_acquire);
}
auto ClientSession::peer_name() const -> const std::string& {
return peer_name_;
}
void ClientSession::writer_loop() {
while (!stop_requested_.load(std::memory_order_acquire)) {
auto packet = queue_.wait_pop();
if (!packet.has_value()) {
break;
}
if (!write_all(socket_fd_, packet->data(), packet->size())) {
break;
}
}
request_stop();
// Exited flag is set once both threads finish; reader_loop sets it.
}
void ClientSession::reader_loop(std::atomic<double>& shared_vlc_slot) {
std::array<std::uint8_t, kPacketHeaderSize> header_buffer{};
std::vector<std::uint8_t> payload_buffer;
while (!stop_requested_.load(std::memory_order_acquire)) {
if (!read_exact(socket_fd_, header_buffer.data(), header_buffer.size())) {
break;
}
const auto payload_len = decode_u32_little_endian(header_buffer.data() + 4U);
if (payload_len > max_payload_bytes_) {
log_warning(
"closing client " + peer_name_ + " after payload size "
+ std::to_string(payload_len) + " exceeded the configured limit"
);
break;
}
payload_buffer.assign(payload_len, std::uint8_t{0});
if (payload_len > 0U && !read_exact(socket_fd_, payload_buffer.data(), payload_len)) {
break;
}
try {
const auto json = Json::parse(payload_buffer.begin(), payload_buffer.end());
if (json.is_object()) {
const auto found = json.find("vlc");
if (found != json.end() && found->is_number()) {
const double value = found->get<double>();
if (std::isfinite(value)) {
shared_vlc_slot.store(value, std::memory_order_release);
}
}
}
} catch (const Json::parse_error& error) {
log_warning(
"ignoring malformed packet from " + peer_name_ + ": " + error.what()
);
}
}
request_stop();
exited_.store(true, std::memory_order_release);
}
// ----- TcpServer ------------------------------------------------------------
TcpServer::TcpServer(LocatorServerConfig config) : config_(std::move(config)) {
latest_socket_speed_.store(
std::numeric_limits<double>::quiet_NaN(),
std::memory_order_relaxed
);
}
TcpServer::~TcpServer() {
stop();
}
void TcpServer::start() {
if (running_.exchange(true, std::memory_order_acq_rel)) {
return;
}
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
addrinfo* resolved = nullptr;
const auto port_str = std::to_string(config_.port);
const auto gai = ::getaddrinfo(
config_.host.c_str(),
port_str.c_str(),
&hints,
&resolved
);
if (gai != 0 || resolved == nullptr) {
running_.store(false, std::memory_order_release);
throw std::runtime_error(
"locator: getaddrinfo failed for " + config_.host + ":" + port_str
+ " (" + ::gai_strerror(gai) + ")"
);
}
int fd = -1;
for (addrinfo* candidate = resolved; candidate != nullptr; candidate = candidate->ai_next) {
fd = ::socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol);
if (fd < 0) {
continue;
}
int yes = 1;
(void)::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
if (::bind(fd, candidate->ai_addr, candidate->ai_addrlen) == 0) {
break;
}
::close(fd);
fd = -1;
}
::freeaddrinfo(resolved);
if (fd < 0) {
running_.store(false, std::memory_order_release);
throw std::runtime_error(
"locator: failed to bind " + config_.host + ":" + port_str
+ " (" + std::string(std::strerror(errno)) + ")"
);
}
if (::listen(fd, 16) < 0) {
::close(fd);
running_.store(false, std::memory_order_release);
throw std::runtime_error(
"locator: listen() failed (" + std::string(std::strerror(errno)) + ")"
);
}
listen_fd_ = fd;
acceptor_thread_ = std::thread([this]() { acceptor_loop(); });
}
void TcpServer::stop() {
if (!running_.exchange(false, std::memory_order_acq_rel)) {
return;
}
if (listen_fd_ >= 0) {
(void)::shutdown(listen_fd_, SHUT_RDWR);
(void)::close(listen_fd_);
listen_fd_ = -1;
}
if (acceptor_thread_.joinable()) {
acceptor_thread_.join();
}
std::vector<std::unique_ptr<ClientSession>> sessions;
{
std::lock_guard<std::mutex> guard(clients_mutex_);
sessions = std::move(clients_);
clients_.clear();
}
for (auto& session : sessions) {
session->request_stop();
}
for (auto& session : sessions) {
session->join();
}
}
auto TcpServer::is_running() const -> bool {
return running_.load(std::memory_order_acquire);
}
void TcpServer::publish(const ipc::ResultCollection& collection, const FilterParams& filter) {
if (!running_.load(std::memory_order_acquire)) {
return;
}
const auto observations = observations_from_collection(collection, filter);
const auto payload_json = build_payload_json(observations, config_.protocol_version);
auto packet = encode_packet(payload_json, config_.device_id);
cache_latest_packet(packet);
broadcast_packet(packet);
}
auto TcpServer::latest_socket_speed() const -> std::optional<double> {
const double value = latest_socket_speed_.load(std::memory_order_acquire);
if (std::isnan(value)) {
return std::nullopt;
}
return value;
}
void TcpServer::acceptor_loop() {
while (running_.load(std::memory_order_acquire)) {
sockaddr_storage peer_addr{};
socklen_t peer_len = sizeof(peer_addr);
const int client_fd = ::accept(
listen_fd_,
reinterpret_cast<sockaddr*>(&peer_addr),
&peer_len
);
if (client_fd < 0) {
if (errno == EINTR) {
continue;
}
// Listening socket closed during shutdown produces EBADF/EINVAL; bail.
break;
}
reap_finished_clients();
apply_socket_keepalive(client_fd);
auto session = std::make_unique<ClientSession>(
client_fd,
format_peer(peer_addr),
config_.client_queue_size,
config_.max_payload_bytes
);
const auto snapshot = latest_packet_copy();
if (snapshot.has_value()) {
session->enqueue(*snapshot);
}
session->start(latest_socket_speed_);
enroll_client(std::move(session));
}
}
void TcpServer::enroll_client(std::unique_ptr<ClientSession> session) {
std::lock_guard<std::mutex> guard(clients_mutex_);
clients_.push_back(std::move(session));
}
void TcpServer::broadcast_packet(const std::vector<std::uint8_t>& packet) {
std::lock_guard<std::mutex> guard(clients_mutex_);
for (auto& client : clients_) {
client->enqueue(packet);
}
}
void TcpServer::reap_finished_clients() {
std::vector<std::unique_ptr<ClientSession>> to_join;
{
std::lock_guard<std::mutex> guard(clients_mutex_);
auto first_dead = std::partition(
clients_.begin(),
clients_.end(),
[](const std::unique_ptr<ClientSession>& session) {
return !session->has_exited();
}
);
for (auto it = first_dead; it != clients_.end(); ++it) {
to_join.push_back(std::move(*it));
}
clients_.erase(first_dead, clients_.end());
}
for (auto& session : to_join) {
session->request_stop();
session->join();
}
}
void TcpServer::cache_latest_packet(std::vector<std::uint8_t> packet) {
std::lock_guard<std::mutex> guard(latest_packet_mutex_);
latest_packet_ = std::move(packet);
}
auto TcpServer::latest_packet_copy() const -> std::optional<std::vector<std::uint8_t>> {
std::lock_guard<std::mutex> guard(latest_packet_mutex_);
return latest_packet_;
}
} // namespace radar::locator
@@ -2,11 +2,15 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <complex>
#include <exception>
#include <random>
#include <string>
#include <string_view>
#include <stdexcept>
#include <thread>
#include <utility>
#include <vector>
@@ -19,6 +23,60 @@ namespace {
constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
constexpr auto kNativeSweepResponseTimeout = std::chrono::milliseconds(1500);
// One synthetic GPR reflector. `range_m` is its physical depth, `reflection`
// is the dimensionless complex reflection coefficient (|Γ| ≤ 1).
struct MockTarget {
float range_m;
float reflection_magnitude;
};
// Three reflectors at GPR-typical depths: a strong near-surface scatterer,
// a mid-depth target, and a weak deeper one. The magnitudes are tuned so the
// summed S21 stays within unit modulus across the sweep band.
constexpr std::array<MockTarget, 3> kMockTargets = {{
{0.45F, 0.55F},
{1.30F, 0.30F},
{2.90F, 0.18F},
}};
// Group velocity in moderately wet soil (≈ c / sqrt(εr), εr ≈ 4). The choice
// is what maps reflector depth to round-trip phase delay; it is held constant
// to keep the simulator deterministic.
constexpr float kGroundVelocityMps = 1.5e8F;
// Soil attenuation grows with frequency. Calibrated so a target at 3 m sees
// roughly 20 dB extra loss at 6 GHz on top of geometric spreading.
constexpr float kAttenuationCoeffPerMeterAtRefHz = 0.22F;
constexpr float kAttenuationReferenceHz = 6e9F;
// Antenna mismatch dominates S11: simulate one shallow reflection right at
// the connector, plus a small amount of cross-coupling from S21 targets.
constexpr float kS11ConnectorReflection = 0.55F;
constexpr float kS11ConnectorRangeM = 0.02F;
constexpr float kS11CrossCouplingFactor = 0.06F;
// Noise floor in linear voltage units. Real LibreVNA hits ~ 90 dB at 1 kHz
// IFBW; pick something a touch noisier so the GPR processor has to work.
constexpr float kNoiseAmplitudeLinear = 0.004F;
// Minimum simulated dwell so 0-point or pathological configs don't busy-loop.
constexpr auto kMockMinimumSweepDuration = std::chrono::microseconds(50);
// Compute the dwell time the mock pretends to spend on the device. Mirrors
// the real LibreVNA contract: per-point dwell ≈ 1 / IFBW. Capped so absurd
// configs (IFBW ≈ 0 or huge sweeps) cannot freeze the producer for hours.
[[nodiscard]] auto mock_target_sweep_duration(const config::RadarSweepSettings& sweep)
-> std::chrono::nanoseconds {
const float points = std::max(1.0F, static_cast<float>(sweep.points));
const float if_bw = std::max(1.0F, sweep.if_bandwidth_hz);
const double seconds = static_cast<double>(points) / static_cast<double>(if_bw);
const auto duration_ns = std::chrono::nanoseconds(
static_cast<std::chrono::nanoseconds::rep>(seconds * 1e9)
);
constexpr auto kHardCap = std::chrono::seconds(5);
return std::clamp<std::chrono::nanoseconds>(duration_ns, kMockMinimumSweepDuration, kHardCap);
}
[[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool {
constexpr std::array<std::string_view, 5> kRetryableSubstrings = {
"Timeout waiting for expected LibreVNA packet type",
@@ -109,34 +167,82 @@ auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
}
auto LibreVnaMinimalDriver::acquire_mock() -> SweepTrace {
// Synthesise a frequency-domain VNA response containing several discrete
// reflectors at known depths, plus light Gaussian noise. The sweep dwells
// for a realistic duration derived from the configured IF bandwidth so
// downstream stages cannot be flooded faster than a real device would
// produce data.
const auto started_at = std::chrono::steady_clock::now();
const auto target_duration = mock_target_sweep_duration(settings_.sweep);
SweepTrace trace{};
trace.frequency_hz.reserve(settings_.sweep.points);
trace.s11.reserve(settings_.sweep.points);
trace.s21.reserve(settings_.sweep.points);
const auto span_hz = settings_.sweep.stop_hz - settings_.sweep.start_hz;
const auto denominator = settings_.sweep.points > 1U ? static_cast<float>(settings_.sweep.points - 1U) : 1.0F;
const float span_hz = settings_.sweep.stop_hz - settings_.sweep.start_hz;
const float denominator =
settings_.sweep.points > 1U ? static_cast<float>(settings_.sweep.points - 1U) : 1.0F;
// A tiny per-sweep range drift gives the rendered B-scan a visible motion
// signature so the simulator does not look frozen.
const float range_drift_m =
0.01F * std::sin(0.07F * static_cast<float>(sweep_index_));
// Deterministic-per-sweep noise so two consecutive frames look distinct
// but the test stays reproducible for any given sweep index.
std::mt19937 noise_engine(
static_cast<std::uint32_t>(0x9E3779B9ULL ^ sweep_index_)
);
std::normal_distribution<float> noise_dist(0.0F, kNoiseAmplitudeLinear);
for (std::uint32_t point = 0; point < settings_.sweep.points; ++point) {
const auto ratio = static_cast<float>(point) / denominator;
const auto frequency_hz = settings_.sweep.start_hz + span_hz * ratio;
const auto phase = 2.0F * detail::kPi * (frequency_hz / std::max(settings_.mock_signal_hz, 1.0F)) +
static_cast<float>(sweep_index_) * 0.05F;
const auto envelope = 0.6F + 0.4F * std::sin(0.5F * phase);
const float ratio = static_cast<float>(point) / denominator;
const float frequency_hz = settings_.sweep.start_hz + span_hz * ratio;
const float frequency_scale = frequency_hz / kAttenuationReferenceHz;
ipc::Complex32 sample{};
sample.re = envelope * std::cos(phase);
sample.im = envelope * std::sin(phase);
std::complex<float> s21_total{0.0F, 0.0F};
std::complex<float> s11_total{0.0F, 0.0F};
const auto reflection_phase = 0.7F * phase + 0.35F;
const auto reflection_envelope = 0.15F + 0.1F * std::cos(0.25F * phase);
ipc::Complex32 reflection{};
reflection.re = reflection_envelope * std::cos(reflection_phase);
reflection.im = reflection_envelope * std::sin(reflection_phase);
for (const auto& target : kMockTargets) {
const float range_m = target.range_m + range_drift_m;
// Round-trip phase: 2π·f·(2R/v).
const float round_trip_phase =
2.0F * detail::kPi * frequency_hz * (2.0F * range_m / kGroundVelocityMps);
// Geometric spreading: 1/(1 + R) keeps near-zero ranges finite.
const float spreading = 1.0F / (1.0F + range_m);
// Frequency-dependent soil attenuation in linear amplitude.
const float attenuation =
std::exp(-kAttenuationCoeffPerMeterAtRefHz * range_m * frequency_scale);
const std::complex<float> contribution = std::polar<float>(
target.reflection_magnitude * spreading * attenuation,
-round_trip_phase
);
s21_total += contribution;
s11_total += kS11CrossCouplingFactor * contribution;
}
// Antenna mismatch dominates the near-field S11 response.
const float antenna_phase =
2.0F * detail::kPi * frequency_hz * (2.0F * kS11ConnectorRangeM / kGroundVelocityMps);
s11_total += std::polar<float>(kS11ConnectorReflection, -antenna_phase);
// Independent noise per channel; complex variance ≈ kNoiseAmplitudeLinear².
s21_total += std::complex<float>(noise_dist(noise_engine), noise_dist(noise_engine));
s11_total += std::complex<float>(noise_dist(noise_engine), noise_dist(noise_engine));
trace.frequency_hz.push_back(frequency_hz);
trace.s11.push_back(reflection);
trace.s21.push_back(sample);
trace.s11.push_back({.re = s11_total.real(), .im = s11_total.imag()});
trace.s21.push_back({.re = s21_total.real(), .im = s21_total.imag()});
}
// Honour the IFBW-derived dwell time. If generation already took longer
// than the simulated device would have needed (huge `points` × CPU jitter)
// we skip the sleep so the producer does not fall further behind.
const auto elapsed = std::chrono::steady_clock::now() - started_at;
if (elapsed < target_duration) {
std::this_thread::sleep_for(target_duration - elapsed);
}
return trace;
+50 -59
View File
@@ -27,11 +27,10 @@ from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.models.dataset_model import ResultCollection, SweepCollection
from python_app.models.gui_profile_model import GuiProfileModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
from python_app.orchestration.locator_runtime import LocatorTcpService
from python_app.orchestration.pipeline_metrics import PipelineMetrics
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.shm_reader import ShmRingReader
@@ -83,7 +82,22 @@ class AppWindow(
self._supervisor = ProcessSupervisor(self._project_root)
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
self._locator_service: LocatorTcpService | None = None
# `log_sink` is attached after the runtime log widget exists.
self._pipeline_metrics = PipelineMetrics(
report_every=self._resolve_metrics_report_every()
)
@staticmethod
def _resolve_metrics_report_every() -> int:
"""Read the metrics flush threshold from env, falling back to 50."""
raw = os.environ.get("RADAR_SYSTEM_METRICS_REPORT_EVERY", "").strip()
if not raw:
return 50
try:
value = int(raw)
except ValueError:
return 50
return value if value >= 1 else 50
def _init_config_profile_state(self) -> None:
"""Resolve startup profile path, load active profile, and queue fallback notices."""
@@ -112,7 +126,6 @@ class AppWindow(
"INFO",
f"Loaded legacy run config without GUI defaults: {active_profile_path}",
)
self._locator_service = self._build_locator_service(self._defaults_config)
self._remember_active_profile_path(active_profile_path, startup=True)
def _init_reader_handles(self) -> None:
@@ -217,60 +230,13 @@ class AppWindow(
self._log(f"Active config profile: {self._active_profile_path}")
self._refresh_preprocess_summary_labels()
self._apply_initial_radar_limits()
self._start_locator_service()
self._on_processing_mode_changed(self._processing_mode.currentText())
self._write_live_processing_config()
# Defer the log sink wiring until the runtime log widget exists.
self._pipeline_metrics.set_log_sink(self._log)
self._timer.start()
self._maybe_auto_start_pipeline()
def _start_locator_service(self) -> None:
"""Start embedded locator TCP service without failing the GUI."""
try:
if self._locator_service is None:
self._locator_service = self._build_locator_service(self._defaults_config)
self._locator_service.start()
self._locator_service.publish_empty()
self._log(
f"Locator TCP server listening on "
f"{self._locator_service.host}:{self._locator_service.port}"
)
except Exception as exc: # noqa: BLE001
self._log_exception("Failed to start locator TCP server", exc, level="WARN")
def _build_locator_service(self, config: RunConfigModel) -> LocatorTcpService:
"""Create locator service instance from stable run config."""
locator_server = config.runtime.locator_server
return LocatorTcpService(
host=str(locator_server.host),
port=int(locator_server.port),
device_id=int(locator_server.device_id),
protocol_version=int(locator_server.protocol_version),
max_payload_bytes=int(locator_server.max_payload_bytes),
client_queue_size=int(locator_server.client_queue_size),
logger_name=str(locator_server.logger_name),
)
def _reload_locator_service_from_config(self) -> None:
"""Rebuild locator service using current stable config and restart if needed."""
previous_service = self._locator_service
was_running = previous_service is not None and previous_service.is_running()
if previous_service is not None:
previous_service.stop()
self._locator_service = self._build_locator_service(self._defaults_config)
if not was_running:
return
try:
self._locator_service.start()
self._locator_service.publish_empty()
self._log(
"Locator TCP server reloaded from config: "
f"{self._locator_service.host}:{self._locator_service.port}"
)
except Exception as exc: # noqa: BLE001
self._log_exception("Failed to reload locator TCP server from config", exc, level="WARN")
def _resolve_startup_profile_path(self) -> Path:
"""Resolve active profile path from session-state or root fallback path."""
env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
@@ -300,13 +266,37 @@ class AppWindow(
return profile_path
def _maybe_auto_start_pipeline(self) -> None:
"""Schedule pipeline start when requested by launcher environment."""
auto_start = os.environ.get("RADAR_SYSTEM_AUTO_START", "").strip().lower()
if auto_start not in {"1", "true", "yes", "on"}:
"""Schedule pipeline start when requested by launcher environment.
With `RADAR_SYSTEM_AUTO_APPLY_RADAR=1` the launcher also reproduces the
"Apply Radar" click before "Start". This is the headless deployment
recipe: the GUI configures the device exactly as a human operator
would, then starts the capture pipeline.
"""
auto_start = self._is_truthy_env("RADAR_SYSTEM_AUTO_START")
if not auto_start:
return
self._log("Auto-start requested by launcher.")
if self._is_truthy_env("RADAR_SYSTEM_AUTO_APPLY_RADAR"):
QTimer.singleShot(500, self._auto_apply_radar_then_start)
else:
QTimer.singleShot(500, self._start_run)
def _auto_apply_radar_then_start(self) -> None:
"""Apply current radar settings then start the pipeline (headless boot)."""
try:
self._apply_radar_settings()
except Exception as exc: # noqa: BLE001
self._log_exception("Auto apply-radar failed", exc, level="WARN")
# Hand control back to the event loop so widget updates from
# _apply_radar_settings can flush before _start_run takes over.
QTimer.singleShot(100, self._start_run)
@staticmethod
def _is_truthy_env(name: str) -> bool:
"""Return True when an environment variable is set to a truthy literal."""
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _normalize_profile_path(self, path: Path) -> Path:
"""Return normalized absolute profile path."""
return path.expanduser().resolve(strict=False)
@@ -494,6 +484,8 @@ class AppWindow(
def _show_error(self, message: str, *, details: str | None = None) -> None:
"""Log and present an error in a modal dialog with optional detail text."""
self._log_error(message, details=details)
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
return
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Critical)
dialog.setWindowTitle("Error")
@@ -505,6 +497,8 @@ class AppWindow(
def _show_exception(self, context: str, exc: Exception) -> None:
"""Log full exception details and show modal dialog with expandable traceback."""
message, details = self._log_exception(context, exc, level="ERROR")
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
return
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Critical)
dialog.setWindowTitle("Error")
@@ -520,9 +514,6 @@ class AppWindow(
self._abort_capture_sequence(resume_pipeline=False)
# 2) Stop all managed processes/readers.
self._stop_all_processes()
# 3) Stop embedded locator service.
if self._locator_service is not None:
self._locator_service.stop()
# 3) Close auxiliary dialog windows.
if self._preprocess_dialog is not None:
self._preprocess_dialog.close()
@@ -89,6 +89,9 @@ class AppWindowLiveProcessingMixin:
gpr_background_mean_count=gpr_background_mean_count,
gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
gpr_min_visible_score=float(self._gpr_min_visible_score.value()),
legacy_gpr_min_visible_pair_count=float(self._legacy_gpr_min_visible_pair_count.value()),
ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
reprocess_current_result=bool(reprocess_current_result),
history_command_seq=int(self._history_command_seq),
history_command=str(history_command),
@@ -140,6 +143,14 @@ class AppWindowLiveProcessingMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update live processing settings", exc)
def _on_legacy_gpr_ignore_socket_speed_toggled(self, ignore_socket_speed: bool) -> None:
"""Reflect socket/manual speed authority in the GUI and live config."""
try:
self._legacy_gpr_speed_m_s.setEnabled(bool(ignore_socket_speed))
self._write_live_processing_config()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update socket-speed mode", exc)
def _on_gpr_visual_settings_changed(self, *_args) -> None:
"""Redraw current GPR result using updated GUI-only render settings."""
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
@@ -152,22 +163,22 @@ class AppWindowLiveProcessingMixin:
self._show_exception("Failed to update GPR render settings", exc)
def _on_gpr_locator_threshold_changed(self, *_args) -> None:
"""Redraw GPR view and republish locator snapshot after threshold changes."""
"""Redraw GPR view; locator delivery lives in the C++ data_processor."""
self._on_gpr_visual_settings_changed()
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
return
try:
self._publish_locator_snapshot_from_latest_result()
self._write_live_processing_config()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update locator GPR threshold", exc)
def _on_gpr_locator_window_changed(self, *_args) -> None:
"""Redraw GPR view and republish locator snapshot after visible X/Z changes."""
"""Redraw GPR view; locator delivery lives in the C++ data_processor."""
self._on_gpr_visual_settings_changed()
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
return
try:
self._publish_locator_snapshot_from_latest_result()
self._write_live_processing_config()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update locator GPR window", exc)
@@ -205,10 +216,6 @@ class AppWindowLiveProcessingMixin:
self._set_plot_mode(mode)
self._set_processing_mode_page(mode)
self._on_processing_live_settings_changed()
if self._is_gpr_processing_mode(mode):
self._publish_locator_snapshot_from_latest_result()
elif self._is_gpr_processing_mode(previous_mode) and self._locator_service is not None:
self._locator_service.publish_empty()
if mode == "pass_through":
self._log(
"Processing mode selected: pass_through "
@@ -445,9 +445,11 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_start_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.start_freq_mhz))
self._legacy_gpr_stop_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.stop_freq_mhz))
self._legacy_gpr_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s))
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled)
ignore_socket_speed_enabled = bool(
gui_state.processing.legacy_gpr.ignore_socket_speed_enabled
)
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(ignore_socket_speed_enabled)
self._legacy_gpr_speed_m_s.setEnabled(ignore_socket_speed_enabled)
self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
self._legacy_gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
@@ -494,7 +496,6 @@ class AppWindowConfigProfileIOMixin:
self._apply_initial_radar_limits()
if self._preprocess_dialog is not None:
self._refresh_sets()
self._reload_locator_service_from_config()
self._on_processing_mode_changed(gui_state.processing.selected_mode)
self._update_history_indicator()
self._remember_active_profile_path(profile_path)
@@ -3,6 +3,7 @@
from __future__ import annotations
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
from python_app.hardware_full.single_radar_service import create_single_radar_service
@@ -22,6 +23,8 @@ class AppWindowRadarLimitsMixin:
return self._apply_radar_limits_to_ui(None)
if config.is_multi_device:
radar_service = LibreVnaService(serial=config.radar.serial or None)
elif config.is_matrix_radar:
radar_service = create_matrix_radar_service(config)
else:
radar_service = create_single_radar_service(config)
@@ -399,8 +399,8 @@ class AppWindowConfigStateBuildersMixin:
config.runtime.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
if config.is_multi_device:
if len(config.radar.multi_device.slave_serials) != 2:
if config.is_matrix_radar:
if config.is_multi_device and len(config.radar.multi_device.slave_serials) != 2:
raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
config.apply_device_model_constraints()
else:
@@ -4,7 +4,6 @@ from __future__ import annotations
import time
from PyQt6.QtCore import QSignalBlocker
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
@@ -12,7 +11,6 @@ from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_con
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.gpr_locator import collection_has_gpr_payloads
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS,
REQUIRED_PREPROCESS_ASSET_KEYS,
@@ -42,8 +40,18 @@ class AppWindowPipelineMixin:
)
return
if self._supervisor.is_running():
self._show_error("Pipeline is already running", details=self._process_state_details())
# A single-shot capture from a running continuous pipeline must
# restart acquisition with `runtime.continuous=false`; refusing
# here would leave the previous run streaming and the user could
# never reach the single-capture termination state.
if not single_capture:
self._show_error(
"Pipeline is already running",
details=self._process_state_details(),
)
return
self._log("Stopping continuous pipeline before single capture")
self._stop_run()
try:
processor_was_running = self._supervisor.is_processor_running()
@@ -198,6 +206,9 @@ class AppWindowPipelineMixin:
if config.is_multi_device:
self._log("Multi-device raw producer will configure all LibreVNA devices")
return
if config.is_matrix_radar:
self._log("Matrix raw producer will configure the matrix radar")
return
if config.is_kamil_adc:
if apply_kamil_adc_laser_control(config):
self._log("Kamil ADC laser_control applied via Apply Radar")
@@ -280,8 +291,6 @@ class AppWindowPipelineMixin:
self._log_error(report.format())
try:
self._drain_locator_speed_updates()
self._drain_locator_log_updates()
if self._raw_reader is not None:
self._read_all_raw()
self._read_all_preprocessed()
@@ -294,7 +303,14 @@ class AppWindowPipelineMixin:
return
return
if result_latest is not None:
render_started_ns = time.monotonic_ns()
self._draw_preferred_collection(result_latest=result_latest)
self._pipeline_metrics.record(
"rendering", time.monotonic_ns() - render_started_ns
)
else:
self._draw_preferred_collection(result_latest=None)
except Exception as exc: # noqa: BLE001
signature = (type(exc).__name__, str(exc))
if self._last_reader_error_signature == signature:
@@ -347,6 +363,11 @@ class AppWindowPipelineMixin:
break
self._raw_history.append(collection)
latest = collection
# `capture_*_ns` are populated by the C++ sweep_orchestrator with
# wallclocks captured around the actual device read. Pre-orchestrator
# producers leave them zero, in which case PipelineMetrics drops it.
acquisition_ns = int(collection.capture_end_ns) - int(collection.capture_start_ns)
self._pipeline_metrics.record("acquisition", acquisition_ns)
if self._single_capture_active and self._single_capture_start_ns is not None:
if collection.monotonic_ns >= self._single_capture_start_ns:
self._single_capture_seen_raw = True
@@ -373,13 +394,9 @@ class AppWindowPipelineMixin:
collection = self._result_reader.pop_result_collection()
if collection is None:
break
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
if record_result_history(self._result_history, collection):
latest = collection
if (
self._is_gpr_processing_mode(self._processing_mode.currentText())
and collection_has_gpr_payloads(collection)
):
self._publish_locator_snapshot_from_collection(collection)
return latest
def _drain_rings_once_for_history(self) -> None:
@@ -484,60 +501,3 @@ class AppWindowPipelineMixin:
self._live_processing_config(),
)
def _drain_locator_speed_updates(self) -> None:
"""Apply the newest queued locator speed packet to live processing settings."""
if self._locator_service is None:
return
speed_m_s = self._locator_service.drain_speed_updates()
if speed_m_s is None:
return
if self._processing_mode.currentText() != "legacy_gpr":
return
if self._legacy_gpr_ignore_socket_speed_enabled.isChecked():
return
previous_speed_m_s = float(self._legacy_gpr_speed_m_s.value())
with QSignalBlocker(self._legacy_gpr_speed_m_s):
self._legacy_gpr_speed_m_s.setValue(float(speed_m_s))
current_speed_m_s = float(self._legacy_gpr_speed_m_s.value())
if current_speed_m_s == previous_speed_m_s:
return
self._write_live_processing_config(reprocess_current_result=False)
def _drain_locator_log_updates(self) -> None:
"""Append queued locator socket traffic messages to the runtime log."""
if self._locator_service is None:
return
for message in self._locator_service.drain_log_updates():
self._log(message)
def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None:
"""Publish one locator snapshot from a GPR result collection."""
if self._locator_service is None:
return
self._locator_service.publish_collection(
collection,
self._gpr_locator_threshold(),
visible_bounds=self._gpr_visible_object_bounds(),
object_draw_limits=self._gpr_draw_limits(),
)
def _publish_locator_snapshot_from_latest_result(self) -> None:
"""Publish current locator-visible snapshot from latest cached GPR result."""
if self._locator_service is None:
return
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
self._locator_service.publish_empty()
return
if not self._result_history:
self._locator_service.publish_empty()
return
latest = self._result_history[-1]
if not collection_has_gpr_payloads(latest):
self._locator_service.publish_empty()
return
self._publish_locator_snapshot_from_collection(latest)
@@ -390,6 +390,12 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
)
# When the box is unchecked, an external TCP client controls the speed
# through the C++ locator server; disable the spinner so the GUI value
# cannot silently win against the live socket value.
owner._legacy_gpr_speed_m_s.setEnabled(
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
)
owner._legacy_gpr_look_angle_deg = QDoubleSpinBox()
owner._legacy_gpr_look_angle_deg.setDecimals(2)
@@ -516,6 +522,9 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_ignore_socket_speed_enabled.toggled.connect(
owner._on_legacy_gpr_ignore_socket_speed_toggled
)
owner._legacy_gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
+38
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
import os
from pathlib import Path
import signal
import sys
import pyqtgraph as pg
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication
# Ensure imports are resolved when started as a script.
@@ -17,6 +20,38 @@ from python_app.gui.app_window import AppWindow
from python_app.gui.theme import apply_light_theme
def _is_headless() -> bool:
"""Return whether the launcher requested a non-interactive deployment."""
return os.environ.get("RADAR_SYSTEM_HEADLESS", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _install_unix_signal_handlers(app: QApplication, window: AppWindow) -> None:
"""Route SIGINT and SIGTERM through the Qt event loop into a clean shutdown.
`window.close()` runs `closeEvent`, which aborts any active capture and
shuts down managed C++ processes; only then does the Qt loop exit. A
short repeating timer keeps the Python interpreter pinned in the event
loop just long enough to deliver pending signals.
"""
def _request_shutdown(*_args: object) -> None:
window.close()
app.quit()
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, _request_shutdown)
keepalive_timer = QTimer(app)
keepalive_timer.setInterval(200)
keepalive_timer.timeout.connect(lambda: None)
keepalive_timer.start()
def main() -> int:
"""Run Qt event loop and show main radar control window."""
app = QApplication(sys.argv)
@@ -24,6 +59,9 @@ def main() -> int:
# PyQtGraph foreground controls axis lines, tick text, labels, and titles.
pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#ffffff")
window = AppWindow(PROJECT_ROOT)
if _is_headless():
_install_unix_signal_handlers(app, window)
else:
window.showMaximized()
return app.exec()
@@ -59,9 +59,11 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
raise RuntimeError("SN9000 requires radar.driver_mode='native'")
from python_app.hardware_full.sn9000_service import Sn9000Service
visa_library = config.radar.visa_library or "@ivi"
return Sn9000Service(
host=config.radar.remote_host,
port=config.radar.remote_port,
visa_library=visa_library,
)
raise RuntimeError(f"Unsupported matrix radar model: {model}")
+42 -3
View File
@@ -60,8 +60,6 @@ class Sn9000Service:
if self.timeout_ms <= 0:
raise ValueError("SN9000 timeout_ms must be > 0")
self.visa_library = str(self.visa_library).strip() or "@ivi"
if self.visa_library == "@py" or self.visa_library.endswith("@py"):
raise ValueError("SN9000 requires an IVI/Vendor VISA backend, not pyvisa-py")
@property
def resource(self) -> str:
@@ -146,6 +144,10 @@ class Sn9000Service:
return {
"min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")),
"max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")),
# SN9000 SCPI does not expose IFBW capability queries; use the
# documented hardware sequence (1 Hz .. 300 kHz, manual p. 58, 1261).
"min_ifbw_hz": 1.0,
"max_ifbw_hz": 300_000.0,
"max_points": int(float(instrument.query("SERV:SWE:POIN?"))),
"min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")),
"max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")),
@@ -208,18 +210,37 @@ class Sn9000Service:
def _query_sweep_s_parameters(self, points: int) -> dict[str, np.ndarray]:
instrument = self._require_instrument()
if self._uses_pyvisa_py_backend():
# pyvisa-py HiSLIP loses synchronization when a single packet aggregates
# *OPC? plus multiple binary blocks, so issue trigger and data queries
# one at a time. The corrected-data buffer holds the last completed
# sweep, so reading each S-parameter sequentially is safe.
instrument.write("TRIG:SING")
self._expect_opc("*OPC?", context="SN9000 sweep")
complex_values: dict[str, np.ndarray] = {}
for parameter_name in _S_PARAMETER_QUERY_ORDER:
instrument.write(f"SENS:DATA:CORR? {parameter_name}")
interleaved = self._read_float32_block(
f"SENS:DATA:CORR? {parameter_name}", points * 2
)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values
data_queries = ";".join(f":SENS:DATA:CORR? {name}" for name in _S_PARAMETER_QUERY_ORDER)
instrument.write(f"TRIG:SING;*OPC?;{data_queries}")
opc_token = self._read_ascii_token()
if opc_token != "1":
raise RuntimeError(f"SN9000 sweep returned unexpected *OPC? response: {opc_token!r}")
complex_values: dict[str, np.ndarray] = {}
complex_values = {}
for parameter_name in _S_PARAMETER_QUERY_ORDER:
interleaved = self._read_float32_block(f"SENS:DATA:CORR? {parameter_name}", points * 2)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values
def _uses_pyvisa_py_backend(self) -> bool:
return self.visa_library == "@py" or self.visa_library.endswith("@py")
def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]:
frequency_hz = self._require_frequency_axis()
traces: list[TraceData] = []
@@ -286,8 +307,26 @@ class Sn9000Service:
f"SN9000 response for {context!r} returned {array.size} float32 values, "
f"expected {expected_values}"
)
self._drain_trailing_terminators()
return array
def _drain_trailing_terminators(self) -> None:
"""Consume the SCPI terminator that follows IEEE binary blocks.
SCPI responses end with `\\n`, which over HiSLIP closes the DataEnd
message group. pyvisa-py's HiSLIP layer needs the terminator drained
before the next request, otherwise it loses message-frame
synchronization on subsequent reads.
"""
instrument = self._require_instrument()
deadline = time.monotonic() + 0.2
while time.monotonic() < deadline:
try:
instrument.read_bytes(1, break_on_termchar=True)
return
except Exception:
return
def _read_response_bytes(self, count: int) -> bytes:
instrument = self._require_instrument()
data = instrument.read_bytes(count, break_on_termchar=False)
+3
View File
@@ -80,5 +80,8 @@ class ResultCollection:
collection_id: int
monotonic_ns: int
# Wall-clock nanoseconds spent by the data_processor on `process_collection`
# for this collection. Zero means the producer did not report a measurement.
processing_duration_ns: int = 0
collection_payloads: list[ResultPayload] = field(default_factory=list)
blocks: list[ResultBlock] = field(default_factory=list)
+2
View File
@@ -93,6 +93,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
model.radar.remote_port = int(radar_payload.get("remote_port", model.radar.remote_port))
model.radar.driver_mode = str(radar_payload.get("driver_mode", model.radar.driver_mode))
model.radar.mock_signal_hz = float(radar_payload.get("mock_signal_hz", model.radar.mock_signal_hz))
model.radar.visa_library = str(radar_payload.get("visa_library", model.radar.visa_library))
model.radar.sweep.start_hz = float(sweep_payload.get("start_hz", model.radar.sweep.start_hz))
model.radar.sweep.stop_hz = float(sweep_payload.get("stop_hz", model.radar.sweep.stop_hz))
@@ -393,6 +394,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"remote_port": model.radar.remote_port,
"driver_mode": model.radar.driver_mode,
"mock_signal_hz": model.radar.mock_signal_hz,
"visa_library": model.radar.visa_library,
"multi_device": {
"slave_serials": list(model.radar.multi_device.slave_serials),
"force_external_reference": model.radar.multi_device.force_external_reference,
+1
View File
@@ -103,6 +103,7 @@ class RadarModel:
remote_port: int = 50209
driver_mode: str = "mock"
mock_signal_hz: float = 1_000_000.0
visa_library: str = ""
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel)
+5 -65
View File
@@ -1,10 +1,11 @@
"""Helpers for extracting GPR objects and locator observations from results."""
"""Helpers for extracting GPR objects from result collections.
Locator TCP delivery now lives in the C++ data_processor. This module retains
only the inspection helpers that the GUI uses for plotting.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import numpy as np
from python_app.models.dataset_model import ResultCollection, ResultPayload
@@ -64,64 +65,3 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
return centers[:, :3]
return np.zeros((0, 3), dtype=np.float32)
def locator_observations_from_collection(
collection: ResultCollection,
min_score: float,
*,
visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> list[dict[str, float]]:
"""Build locator observations from GPR rows using score threshold and optional X/Z bounds."""
rows = gpr_object_rows(collection)
if rows.size == 0:
return []
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
visible_mask = finite_mask & (rows[:, 2] >= float(min_score))
if visible_bounds is not None:
x_min, x_max, z_min, z_max = (float(value) for value in visible_bounds)
visible_mask &= (
(rows[:, 0] >= x_min)
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
filtered = rows[visible_mask]
if object_draw_limits is not None and filtered.size > 0:
max_detected_objects, draw_top_objects = object_draw_limits
if filtered.shape[0] > int(max_detected_objects):
filtered = np.zeros((0, filtered.shape[1]), dtype=filtered.dtype)
else:
filtered = filtered[: max(0, int(draw_top_objects))]
observations: list[dict[str, float]] = []
for x_m, z_m, _score in filtered:
observations.append(
{
"dst": round(float(z_m), 2),
"crs": round(float(x_m), 2),
}
)
return observations
def build_locator_payload(
observations: list[dict[str, float]],
*,
protocol_version: int,
status: int = 1,
) -> dict[str, Any]:
"""Assemble one outbound locator payload from precomputed observations."""
return {
"ver": int(protocol_version),
"tim": _format_timestamp(),
"sts": int(status),
"obs": observations,
}
def _format_timestamp() -> str:
"""Return wall-clock timestamp with millisecond precision."""
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
@@ -44,6 +44,12 @@ class ProcessingLiveConfig:
gpr_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True
gpr_imaging_plane_y_m: float = 0.0
# Locator filter parameters consumed by the C++ TCP locator server.
gpr_min_visible_score: float = 0.0
legacy_gpr_min_visible_pair_count: float = 0.0
# When true, the C++ data_processor ignores socket-supplied vlc updates
# and keeps using `gpr_speed_m_s` from this file.
ignore_socket_speed: bool = False
reprocess_current_result: bool = True
history_command_seq: int = 0
history_command: str = "none"
@@ -95,6 +101,9 @@ class ProcessingLiveConfig:
"gpr_background_mean_count": int(self.gpr_background_mean_count),
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
"gpr_min_visible_score": float(self.gpr_min_visible_score),
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
"ignore_socket_speed": bool(self.ignore_socket_speed),
"reprocess_current_result": bool(self.reprocess_current_result),
"history_command_seq": int(self.history_command_seq),
"history_command": str(self.history_command),
-460
View File
@@ -1,460 +0,0 @@
"""Event-driven locator TCP service fed by already-consumed GUI GPR results."""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
import json
import logging
import math
import queue
import struct
import threading
from typing import Any
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.gpr_locator import (
build_locator_payload,
locator_observations_from_collection,
)
_PACKET_HEADER_STRUCT = struct.Struct("<II")
def encode_packet(payload: dict[str, Any], device_id: int) -> bytes:
"""Serialize a JSON payload with the protocol binary header."""
payload_bytes = json.dumps(
payload,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
return _PACKET_HEADER_STRUCT.pack(device_id, len(payload_bytes)) + payload_bytes
def decode_packet(header_bytes: bytes, payload_bytes: bytes) -> tuple[int, Any]:
"""Decode one protocol packet from its binary header and JSON payload."""
if len(header_bytes) != _PACKET_HEADER_STRUCT.size:
raise ValueError(f"Packet header must be exactly {_PACKET_HEADER_STRUCT.size} bytes long.")
device_id, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
if payload_length != len(payload_bytes):
raise ValueError("Payload length does not match the header value.")
try:
payload = json.loads(payload_bytes.decode("utf-8"))
except UnicodeDecodeError as error:
raise ValueError("Payload is not valid UTF-8.") from error
except json.JSONDecodeError as error:
raise ValueError("Payload is not valid JSON.") from error
return device_id, payload
def parse_vlc(payload: dict[str, Any]) -> float:
"""Validate and normalize inbound speed payload."""
try:
vlc = float(payload["vlc"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Payload field 'vlc' must be numeric.") from error
if not math.isfinite(vlc):
raise ValueError("Payload field 'vlc' must be finite.")
return vlc
def format_payload_for_log(payload: Any) -> str:
"""Return compact JSON-ish payload text for logs."""
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def decode_packet_for_log(packet: bytes) -> tuple[int, str]:
"""Decode an outbound packet into `(device_id, payload_text)` for logging."""
if len(packet) < _PACKET_HEADER_STRUCT.size:
raise ValueError("Packet is shorter than the locator header")
header_bytes = packet[: _PACKET_HEADER_STRUCT.size]
payload_bytes = packet[_PACKET_HEADER_STRUCT.size :]
device_id, payload = decode_packet(header_bytes, payload_bytes)
return device_id, format_payload_for_log(payload)
def format_peer_name(writer: asyncio.StreamWriter) -> str:
"""Return a readable peer address for logs."""
peer_name = writer.get_extra_info("peername")
if isinstance(peer_name, tuple) and len(peer_name) >= 2:
return f"{peer_name[0]}:{peer_name[1]}"
return str(peer_name or "unknown")
async def read_packet_with_limit(reader: asyncio.StreamReader, max_payload_bytes: int) -> tuple[int, Any]:
"""Read and decode a single packet using the requested payload limit."""
header_bytes = await reader.readexactly(_PACKET_HEADER_STRUCT.size)
_, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
if payload_length > int(max_payload_bytes):
raise ValueError(
"Payload length %d exceeds the %d byte limit."
% (payload_length, int(max_payload_bytes))
)
payload_bytes = await reader.readexactly(payload_length)
return decode_packet(header_bytes, payload_bytes)
@dataclass(eq=False, slots=True)
class _ClientConnection:
"""Runtime state for one connected locator client."""
writer: asyncio.StreamWriter
peer_name: str
queue: asyncio.Queue[bytes]
closed: bool = False
class LocatorTcpService:
"""Background-thread TCP service for locator packets."""
def __init__(
self,
host: str,
port: int,
*,
device_id: int,
protocol_version: int,
max_payload_bytes: int,
client_queue_size: int,
logger_name: str,
logger: logging.Logger | None = None,
) -> None:
"""Create a stopped service instance."""
self._host = host
self._port = int(port)
self._device_id = int(device_id)
self._protocol_version = int(protocol_version)
self._max_payload_bytes = int(max_payload_bytes)
self._logger = logger or logging.getLogger(str(logger_name))
self._client_queue_size = int(client_queue_size)
self._speed_updates: queue.Queue[float] = queue.Queue()
self._log_updates: queue.Queue[str] = queue.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._server: asyncio.AbstractServer | None = None
self._thread: threading.Thread | None = None
self._startup_event = threading.Event()
self._startup_error: Exception | None = None
self._clients: set[_ClientConnection] = set()
self._snapshot_lock = threading.Lock()
self._latest_packet: bytes | None = None
@property
def host(self) -> str:
"""Return bind host."""
return self._host
@property
def port(self) -> int:
"""Return bind port."""
return self._port
def start(self) -> None:
"""Start the background event loop and TCP listener."""
if self.is_running():
return
self._startup_event = threading.Event()
self._startup_error = None
self._thread = threading.Thread(
target=self._thread_main,
name="locator-tcp-service",
daemon=True,
)
self._thread.start()
if not self._startup_event.wait(timeout=5.0):
raise RuntimeError("Timed out waiting for locator TCP service startup.")
if self._startup_error is not None:
error = self._startup_error
self.stop()
raise RuntimeError(f"Failed to start locator TCP service: {error}") from error
def stop(self) -> None:
"""Stop listener, disconnect clients, and join the background thread."""
loop = self._loop
thread = self._thread
if loop is not None:
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(loop.stop)
if thread is not None:
thread.join(timeout=5.0)
self._thread = None
self._loop = None
self._server = None
self._clients.clear()
def is_running(self) -> bool:
"""Return whether the background loop is alive."""
return self._thread is not None and self._thread.is_alive() and self._loop is not None
def publish_collection(
self,
collection: ResultCollection,
min_score: float,
*,
visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> None:
"""Publish one locator payload derived from a GPR result collection."""
observations = locator_observations_from_collection(
collection,
min_score,
visible_bounds=visible_bounds,
object_draw_limits=object_draw_limits,
)
payload = build_locator_payload(
observations,
protocol_version=self._protocol_version,
status=1,
)
self._publish_packet(encode_packet(payload, device_id=self._device_id))
def publish_empty(self) -> None:
"""Publish an empty locator snapshot."""
payload = build_locator_payload(
[],
protocol_version=self._protocol_version,
status=1,
)
self._publish_packet(encode_packet(payload, device_id=self._device_id))
def drain_speed_updates(self) -> float | None:
"""Drain queued speed updates and return the newest one, if any."""
latest: float | None = None
while True:
try:
latest = float(self._speed_updates.get_nowait())
except queue.Empty:
return latest
def drain_log_updates(self) -> list[str]:
"""Drain queued socket traffic log lines."""
lines: list[str] = []
while True:
try:
lines.append(str(self._log_updates.get_nowait()))
except queue.Empty:
return lines
def _queue_log_update(self, message: str) -> None:
"""Queue one socket traffic line for the GUI runtime log."""
self._log_updates.put(str(message))
def _log_socket_traffic(self, message: str) -> None:
"""Log socket traffic to both Python logging and the GUI-visible queue."""
self._logger.info(message)
self._queue_log_update(message)
def _publish_packet(self, packet: bytes) -> None:
"""Store latest packet and broadcast it to all connected clients."""
with self._snapshot_lock:
self._latest_packet = packet
loop = self._loop
if loop is None:
return
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(self._broadcast_packet, packet)
def _get_latest_packet(self) -> bytes | None:
"""Return the latest stored packet snapshot."""
with self._snapshot_lock:
return self._latest_packet
def _thread_main(self) -> None:
"""Own the event loop and TCP listener lifecycle."""
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
try:
self._server = loop.run_until_complete(
asyncio.start_server(self._handle_client, self._host, self._port)
)
except Exception as exc: # noqa: BLE001
self._startup_error = exc
self._startup_event.set()
self._loop = None
asyncio.set_event_loop(None)
loop.close()
return
self._startup_event.set()
try:
loop.run_forever()
finally:
with contextlib.suppress(Exception):
loop.run_until_complete(self._shutdown_async())
asyncio.set_event_loop(None)
loop.close()
self._server = None
self._loop = None
async def _shutdown_async(self) -> None:
"""Close listener and all active client connections."""
server = self._server
if server is not None:
server.close()
await server.wait_closed()
clients = list(self._clients)
self._clients.clear()
for client in clients:
client.closed = True
client.writer.close()
for client in clients:
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
await client.writer.wait_closed()
pending = [
task
for task in asyncio.all_tasks()
if task is not asyncio.current_task()
]
for task in pending:
task.cancel()
for task in pending:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle one client until disconnect or protocol failure."""
peer_name = format_peer_name(writer)
client = _ClientConnection(
writer=writer,
peer_name=peer_name,
queue=asyncio.Queue(maxsize=self._client_queue_size),
)
self._clients.add(client)
self._logger.info("Locator client connected: %s", peer_name)
latest_packet = self._get_latest_packet()
if latest_packet is not None:
self._enqueue_packet(client, latest_packet)
send_task = asyncio.create_task(
self._send_packets(client),
name=f"locator_send:{peer_name}",
)
receive_task = asyncio.create_task(
self._receive_packets(reader, client),
name=f"locator_receive:{peer_name}",
)
done, pending = await asyncio.wait(
{send_task, receive_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in pending:
with contextlib.suppress(asyncio.CancelledError):
await task
self._clients.discard(client)
client.closed = True
writer.close()
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
await writer.wait_closed()
for task in done:
exception = task.exception()
if exception is None:
continue
if isinstance(exception, asyncio.IncompleteReadError):
self._logger.info("Locator client closed the connection: %s", peer_name)
continue
if isinstance(exception, (BrokenPipeError, ConnectionResetError)):
self._logger.info("Locator connection lost: %s", peer_name)
continue
if isinstance(exception, ValueError):
self._logger.warning(
"Closing locator client %s after protocol error: %s",
peer_name,
exception,
)
continue
self._logger.error(
"Unexpected locator client error: %s",
peer_name,
exc_info=(type(exception), exception, exception.__traceback__),
)
self._logger.info("Locator client disconnected: %s", peer_name)
async def _send_packets(self, client: _ClientConnection) -> None:
"""Drain one client's outbound queue."""
while True:
packet = await client.queue.get()
client.writer.write(packet)
await client.writer.drain()
try:
device_id, payload_text = decode_packet_for_log(packet)
self._log_socket_traffic(
"Locator socket sent to %s: device_id=%d payload=%s"
% (client.peer_name, device_id, payload_text)
)
except ValueError as error:
self._log_socket_traffic(
"Locator socket sent undecodable packet to %s: bytes=%d error=%s"
% (client.peer_name, len(packet), error)
)
async def _receive_packets(
self,
reader: asyncio.StreamReader,
client: _ClientConnection,
) -> None:
"""Receive inbound client packets and queue valid speed updates."""
while True:
device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes)
payload_text = format_payload_for_log(payload)
if isinstance(payload, dict) and "vlc" in payload:
speed_m_s = parse_vlc(payload)
self._speed_updates.put(speed_m_s)
self._log_socket_traffic(
"Locator socket received from %s: device_id=%d payload=%s speed_m_s=%g"
% (client.peer_name, device_id, payload_text, speed_m_s)
)
continue
self._log_socket_traffic(
"Locator socket received from %s: device_id=%d payload=%s"
% (client.peer_name, device_id, payload_text)
)
def _broadcast_packet(self, packet: bytes) -> None:
"""Enqueue one packet for all connected clients."""
for client in list(self._clients):
self._enqueue_packet(client, packet)
def _enqueue_packet(self, client: _ClientConnection, packet: bytes) -> None:
"""Enqueue one packet or disconnect a backpressured client."""
if client.closed:
return
try:
client.queue.put_nowait(packet)
except asyncio.QueueFull:
client.closed = True
self._logger.warning(
"Disconnecting locator client %s after outbound queue overflow.",
client.peer_name,
)
client.writer.close()
@@ -0,0 +1,104 @@
"""Rolling pipeline timing metrics emitted to the runtime log.
Three independent samples are accumulated:
* acquisition `capture_end_ns - capture_start_ns` from each raw sweep
* processing `processing_duration_ns` from each result collection
* rendering wall time of the Python render call
Each metric flushes an averaged report to a caller-supplied logger as soon as
its rolling buffer reaches `report_every` samples (default 50). Metrics are
strictly read-only: malformed or missing input is silently ignored so a busy
pipeline never blocks on a stray sample.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True, slots=True)
class MetricReport:
"""Summary of one rolling-window flush.
All durations are in nanoseconds. `n` is the number of samples that fed the
summary never less than 1. `min_ns` / `max_ns` mark the extremes of the
window so spikes are visible even when the average stays calm.
"""
name: str
n: int
avg_ns: int
min_ns: int
max_ns: int
def format_ms(self) -> str:
"""Format the summary as a one-line `ms`-scaled log message."""
return (
f"metrics: {self.name} n={self.n} "
f"avg={self.avg_ns / 1_000_000:.2f}ms "
f"min={self.min_ns / 1_000_000:.2f}ms "
f"max={self.max_ns / 1_000_000:.2f}ms"
)
class PipelineMetrics:
"""Accumulate per-stage durations and flush averaged reports.
The caller supplies a `log_sink` (a function taking a single string) that
receives one report line per flushed metric. Wiring `log_sink` to the GUI
log writer keeps metric output co-located with the rest of the runtime
log; routing it to `print` keeps the class trivially unit-testable.
"""
def __init__(
self,
*,
report_every: int = 50,
log_sink: Callable[[str], None] | None = None,
) -> None:
"""Create a collector with a flush threshold and optional log sink."""
if report_every < 1:
raise ValueError("report_every must be >= 1")
self._report_every = int(report_every)
self._log_sink = log_sink
self._buffers: dict[str, deque[int]] = {}
def set_log_sink(self, log_sink: Callable[[str], None] | None) -> None:
"""Reassign the log sink (used when the GUI log appears after init)."""
self._log_sink = log_sink
def record(self, name: str, duration_ns: int) -> MetricReport | None:
"""Append one sample. Return a flushed report if the buffer is full."""
if duration_ns <= 0:
return None
buffer = self._buffers.setdefault(name, deque())
buffer.append(int(duration_ns))
if len(buffer) < self._report_every:
return None
samples = list(buffer)
buffer.clear()
report = self._summarize(name, samples)
if self._log_sink is not None:
self._log_sink(report.format_ms())
return report
def reset(self) -> None:
"""Discard all buffered samples without emitting a report."""
self._buffers.clear()
@staticmethod
def _summarize(name: str, samples: Iterable[int]) -> MetricReport:
"""Reduce a sample sequence to one report."""
sample_list = list(samples)
total = sum(sample_list)
count = len(sample_list)
return MetricReport(
name=name,
n=count,
avg_ns=total // count,
min_ns=min(sample_list),
max_ns=max(sample_list),
)
+3 -1
View File
@@ -16,7 +16,7 @@ from python_app.orchestration.shm.binary_cursor import ByteCursor
RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050
RESULT_MAGIC = 0x314C5352
RESULT_MAGIC = 0x324C5352 # RSL2: adds processing_duration_ns after monotonic_ns
def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollection:
@@ -136,6 +136,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
collection_id = cursor.read_u64()
monotonic_ns = cursor.read_u64()
processing_duration_ns = cursor.read_u64()
collection_payload_count = cursor.read_u32()
block_count = cursor.read_u32()
@@ -163,6 +164,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
return ResultCollection(
collection_id=collection_id,
monotonic_ns=monotonic_ns,
processing_duration_ns=processing_duration_ns,
collection_payloads=collection_payloads,
blocks=blocks,
)
@@ -75,8 +75,8 @@ def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
config = RunConfigModel.from_dict(config_payload)
if config.radar.driver_mode != "native":
return "Radar pre-configuration skipped (mock mode)."
if config.is_multi_device:
return "Radar pre-configuration skipped (multi-device producer config)."
if config.is_matrix_radar:
return "Radar pre-configuration skipped (matrix radar producer config)."
radar_service = create_single_radar_service(config)
if not getattr(radar_service, "driver_available", True):
+3 -2
View File
@@ -10,7 +10,7 @@ from python_app.models.dataset_model import ResultCollection, SweepCollection
RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050
RESULT_MAGIC = 0x314C5352
RESULT_MAGIC = 0x324C5352 # RSL2: adds processing_duration_ns after monotonic_ns
def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
@@ -109,10 +109,11 @@ def serialize_result_collection(collection: ResultCollection) -> bytes:
buffer = bytearray()
buffer.extend(
struct.pack(
"<IQQII",
"<IQQQII",
RESULT_MAGIC,
collection.collection_id,
collection.monotonic_ns,
int(collection.processing_duration_ns),
len(collection.collection_payloads),
len(collection.blocks),
)
+2 -2
View File
@@ -19,9 +19,9 @@ def capture_calibration_set(
median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as calibration set."""
if config.is_multi_device:
if config.is_matrix_radar:
raise RuntimeError(
"LibreVNA multi-device S21 through calibration is not supported by this one-shot full-set helper. "
"Matrix-radar S21 through calibration is not supported by this one-shot full-set helper. "
"Use the sequential preprocess capture flow so each virtual combo can be connected through "
"and captured explicitly."
)
@@ -16,7 +16,7 @@ from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import (
MULTI_DEVICE_MANUAL_CAPTURE_KINDS,
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
SequentialCaptureState,
combine_collections_via_median,
combine_traces_via_median,
@@ -75,8 +75,9 @@ class MultiRadarSequentialCaptureSession:
self._radar_variants = list(radar_variants)
self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = base_config.is_matrix_radar
self._is_multi_device = base_config.is_multi_device
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar
@@ -169,7 +170,7 @@ class MultiRadarSequentialCaptureSession:
set_name=self._set_name,
captured_count=(
self._next_index
if self._is_matrix_radar and not self._manual_multi_device_capture
if self._is_matrix_radar and not self._manual_matrix_radar_capture
else len(self._captured_batches)
),
total_count=len(self._combos),
@@ -177,7 +178,7 @@ class MultiRadarSequentialCaptureSession:
can_undo=bool(self._captured_batches),
is_complete=self.is_complete(),
variant_count=len(self._radar_variants),
supports_batch_capture=not self._manual_multi_device_capture,
supports_batch_capture=not self._manual_matrix_radar_capture,
)
def capture_current_combo(self) -> MultiRadarCaptureBatch:
@@ -205,7 +206,7 @@ class MultiRadarSequentialCaptureSession:
f"Matrix radar variant {variant.display_name} returned no traces"
)
collections.append(collection)
if self._manual_multi_device_capture:
if self._manual_matrix_radar_capture:
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces)
pending_traces_by_radar_key[variant.radar_key] = [trace]
@@ -251,7 +252,7 @@ class MultiRadarSequentialCaptureSession:
variant_labels=tuple(variant_labels),
)
self._captured_batches.append(batch)
if self._is_matrix_radar and not self._manual_multi_device_capture:
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
self._next_index = len(self._combos)
else:
self._next_index += 1
@@ -264,7 +265,7 @@ class MultiRadarSequentialCaptureSession:
if not self._captured_batches or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
if self._is_matrix_radar and not self._manual_multi_device_capture:
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
removed_batch = self._captured_batches[-1]
for variant in self._radar_variants:
traces = self._traces_by_radar_key[variant.radar_key]
@@ -15,7 +15,7 @@ from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config
MULTI_DEVICE_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
MATRIX_RADAR_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5
@@ -58,8 +58,9 @@ class SequentialCaptureSession:
self._set_name = set_name
self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = config.is_matrix_radar
self._is_multi_device = config.is_multi_device
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar
@@ -148,7 +149,7 @@ class SequentialCaptureSession:
current_combo=current_combo,
can_undo=bool(self._traces),
is_complete=self.is_complete(),
supports_batch_capture=not self._manual_multi_device_capture,
supports_batch_capture=not self._manual_matrix_radar_capture,
)
def capture_current_combo(self) -> TraceData:
@@ -166,7 +167,7 @@ class SequentialCaptureSession:
if not collection.traces:
raise RuntimeError("Matrix radar capture returned no traces")
collections.append(collection)
if self._manual_multi_device_capture:
if self._manual_matrix_radar_capture:
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces)
self._traces.append(trace)
@@ -208,7 +209,7 @@ class SequentialCaptureSession:
if not self._traces or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
if self._is_matrix_radar and not self._manual_multi_device_capture:
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
if len(self._traces) != len(self._combos):
raise RuntimeError("Capture session state is inconsistent; matrix radar trace matrix is incomplete")
removed_trace = self._traces[-1]
+6 -10
View File
@@ -1,19 +1,15 @@
{
"radar": {
"model": "librevna",
"serial": "",
"driver_mode": "mock",
"mock_signal_hz": 5000000.0,
"multi_device": {
"slave_serials": [],
"force_external_reference": false,
"recovery_attempts": 3
},
"model": "sn9000",
"remote_host": "192.168.2.102",
"remote_port": 4880,
"driver_mode": "native",
"visa_library": "@py",
"sweep": {
"start_hz": 1000000.0,
"stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0,
"if_bandwidth_hz": 10000.0,
"stimulus_power_dbm": -10.0
}
},
+28 -2
View File
@@ -16,6 +16,7 @@ CLEAN_SHM=0
KAMIL_ADC_MODE=0
AUTO_START=0
PRODUCER_ONLY=0
HEADLESS=0
print_usage() {
cat <<'EOF'
@@ -25,6 +26,10 @@ Options:
--kamil-adc Use the Raspberry Pi Kamil ADC profile
--profile PATH Use a specific GUI/run config profile
--auto-start Start the GUI pipeline automatically after launch
--headless Run without a display (Qt offscreen platform) and apply
the active radar config, then start the pipeline. Suitable
for unattended Raspberry Pi deployments. Implies
--auto-start.
--producer-only Run only the raw producer selected by the profile
--skip-build Skip C++ build step
--build-only Build C++ binaries and exit
@@ -59,6 +64,10 @@ parse_args() {
--auto-start)
AUTO_START=1
;;
--headless)
HEADLESS=1
AUTO_START=1
;;
--producer-only)
PRODUCER_ONLY=1
;;
@@ -140,11 +149,14 @@ ensure_python_dependencies() {
exit 1
fi
if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then
if "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then
PYTHON_CMD="${VENV_PYTHON}"
return
fi
echo "[start.sh] Installing Python dependencies into virtual environment..."
"${VENV_PIP}" install --upgrade pip
"${VENV_PIP}" install -r "${REQUIREMENTS_FILE}"
fi
if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then
echo "Required Python dependencies are still unavailable in virtual environment: ${PROJECT_ROOT}/.venv" >&2
@@ -234,6 +246,10 @@ EOF
}
build_cpp_binaries() {
if make -C "${PROJECT_ROOT}" -q all >/dev/null 2>&1; then
echo "[start.sh] C++ binaries are up to date; skipping build."
return
fi
local jobs
jobs="${BUILD_JOBS:-$(nproc)}"
echo "[start.sh] Building C++ binaries (jobs=${jobs})..."
@@ -265,6 +281,16 @@ run_gui() {
export RADAR_SYSTEM_AUTO_START=1
echo "[start.sh] GUI auto-start is enabled."
fi
if ((HEADLESS == 1)); then
# Qt offscreen platform lets the GUI controller and its event loop run
# on a machine with no display (typical Raspberry Pi deployment). All
# backend services — supervisor, Python device drivers, SHM readers,
# locator client (vlc) handling — continue to work unchanged.
export QT_QPA_PLATFORM=offscreen
export RADAR_SYSTEM_HEADLESS=1
export RADAR_SYSTEM_AUTO_APPLY_RADAR=1
echo "[start.sh] Headless mode: Qt offscreen + auto apply-radar + auto-start."
fi
echo "[start.sh] Launching GUI..."
exec "${PYTHON_CMD}" "${GUI_ENTRY}"