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/reference_master/include \
-Idata_acq_and_processing/preprocessing/data_preprocessor/include \ -Idata_acq_and_processing/preprocessing/data_preprocessor/include \
-Idata_acq_and_processing/processing/processors/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 := \ COMMON_SOURCES := \
data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp \ 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/data_preprocessor.cpp \
data_acq_and_processing/preprocessing/data_preprocessor/src/main.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 := \ PROCESSOR_SOURCES := \
data_acq_and_processing/processing/processors/src/bscan_processor.cpp \ 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/gpr_processor.cpp \
data_acq_and_processing/processing/processors/src/passthrough_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/processing_live_config.cpp \
data_acq_and_processing/processing/data_processor/src/data_processor.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)) 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)) PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
@@ -4,6 +4,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "locator/locator_config.hpp"
#include "shared_types.hpp" #include "shared_types.hpp"
namespace radar::config { namespace radar::config {
@@ -148,6 +149,7 @@ struct RunConfig {
RuntimeConfig runtime{}; RuntimeConfig runtime{};
PreprocessConfig preprocess{}; PreprocessConfig preprocess{};
GprConfig gpr{}; GprConfig gpr{};
radar::locator::LocatorServerConfig locator_server{};
std::vector<radar::ipc::ComboKey> run_combos{}; std::vector<radar::ipc::ComboKey> run_combos{};
}; };
@@ -194,6 +194,33 @@ using Json = nlohmann::json;
return notch; 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 { [[nodiscard]] auto parse_driver_mode(const std::string& value) -> DriverMode {
if (value == "mock") { if (value == "mock") {
return DriverMode::Mock; return DriverMode::Mock;
@@ -479,6 +506,12 @@ auto load_run_config(const std::string& path) -> RunConfig {
"python_app/runtime/processing_live.json" "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"); const auto* combos = as_array(required_field(*run_obj, "combos"), "run.combos");
if (combos->empty()) { if (combos->empty()) {
throw std::runtime_error("run.combos must not be empty"); throw std::runtime_error("run.combos must not be empty");
@@ -83,6 +83,11 @@ struct ResultBlock {
struct ResultCollection { struct ResultCollection {
std::uint64_t collection_id = 0; std::uint64_t collection_id = 0;
std::uint64_t monotonic_ns = 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<ResultPayload> collection_payloads{};
std::vector<ResultBlock> blocks{}; std::vector<ResultBlock> blocks{};
}; };
@@ -14,7 +14,7 @@ namespace {
constexpr std::uint32_t kRawCollectionMagic = 0x32574152U; // RAW2 constexpr std::uint32_t kRawCollectionMagic = 0x32574152U; // RAW2
constexpr std::uint32_t kPreprocessedCollectionMagic = 0x32525050U; // PRP2 constexpr std::uint32_t kPreprocessedCollectionMagic = 0x32525050U; // PRP2
constexpr std::uint32_t kResultCollectionMagic = 0x314C5352U; // RSL1 constexpr std::uint32_t kResultCollectionMagic = 0x324C5352U; // RSL2
template <typename T> template <typename T>
concept TriviallySerializable = std::is_trivially_copyable_v<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(kResultCollectionMagic);
writer.write(collection.collection_id); writer.write(collection.collection_id);
writer.write(collection.monotonic_ns); 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.collection_payloads.size(), "Collection payload count"));
writer.write(checked_count_to_u32(collection.blocks.size(), "Result block 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{}; ResultCollection collection{};
collection.collection_id = reader.read<std::uint64_t>(); collection.collection_id = reader.read<std::uint64_t>();
collection.monotonic_ns = 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 collection_payload_count = reader.read<std::uint32_t>();
const auto block_count = reader.read<std::uint32_t>(); const auto block_count = reader.read<std::uint32_t>();
@@ -7,6 +7,7 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "locator/tcp_server.hpp"
#include "processor_interface.hpp" #include "processor_interface.hpp"
#include "processing_live_config.hpp" #include "processing_live_config.hpp"
#include "run_config.hpp" #include "run_config.hpp"
@@ -22,7 +23,8 @@ class DataProcessor {
const config::RunConfig& config, const config::RunConfig& config,
ipc::ShmRing& preprocessed_ring, ipc::ShmRing& preprocessed_ring,
ipc::ShmRing& results_ring, ipc::ShmRing& results_ring,
ProcessorRegistry processors ProcessorRegistry processors,
radar::locator::TcpServer* locator_server = nullptr
); );
void run(const std::atomic<bool>& stop_requested); 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 resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
[[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool; [[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_; const config::RunConfig& config_;
ipc::ShmRing& preprocessed_ring_; ipc::ShmRing& preprocessed_ring_;
ipc::ShmRing& results_ring_; ipc::ShmRing& results_ring_;
ProcessorRegistry processors_{}; ProcessorRegistry processors_{};
std::string default_processor_mode_{}; std::string default_processor_mode_{};
ProcessingLiveConfigLoader live_config_loader_; ProcessingLiveConfigLoader live_config_loader_;
radar::locator::TcpServer* locator_server_ = nullptr;
}; };
[[nodiscard]] auto create_default_processors() -> ProcessorRegistry; [[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. // 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. // Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
float gpr_imaging_plane_y_m = 0.0F; 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; bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0; std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None; HistoryCommand history_command = HistoryCommand::None;
@@ -37,14 +37,16 @@ DataProcessor::DataProcessor(
const config::RunConfig& config, const config::RunConfig& config,
ipc::ShmRing& preprocessed_ring, ipc::ShmRing& preprocessed_ring,
ipc::ShmRing& results_ring, ipc::ShmRing& results_ring,
ProcessorRegistry processors ProcessorRegistry processors,
radar::locator::TcpServer* locator_server
) )
: config_(config), : config_(config),
preprocessed_ring_(preprocessed_ring), preprocessed_ring_(preprocessed_ring),
results_ring_(results_ring), results_ring_(results_ring),
processors_(std::move(processors)), processors_(std::move(processors)),
default_processor_mode_(kDefaultProcessorMode), 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()) { if (processors_.empty()) {
throw std::runtime_error("DataProcessor requires at least one processor"); 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; std::uint64_t last_applied_history_command_seq = 0;
while (!stop_requested.load(std::memory_order_relaxed)) { 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(); const auto live_revision = live_config_loader_.revision();
auto& processor = resolve_processor(live_config); auto& processor = resolve_processor(live_config);
@@ -89,6 +92,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config live_config
); );
publish_result_collection(replay_result, results_ring_); publish_result_collection(replay_result, results_ring_);
publish_locator(replay_result, live_config);
} }
} else if (!preprocessed_history.empty()) { } else if (!preprocessed_history.empty()) {
const auto replay_result = process_collection( const auto replay_result = process_collection(
@@ -98,6 +102,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config live_config
); );
publish_result_collection(replay_result, results_ring_); publish_result_collection(replay_result, results_ring_);
publish_locator(replay_result, live_config);
} }
last_replayed_revision = live_revision; last_replayed_revision = live_revision;
} }
@@ -116,6 +121,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config live_config
); );
publish_result_collection(result_collection, results_ring_); publish_result_collection(result_collection, results_ring_);
publish_locator(result_collection, live_config);
continue; continue;
} }
@@ -129,7 +135,13 @@ auto DataProcessor::process_collection(
ProcessorInterface& processor, ProcessorInterface& processor,
const ProcessingLiveConfig& live_config const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection { ) -> 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& { 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"; 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 { auto create_default_processors() -> ProcessorRegistry {
ProcessorRegistry processors{}; ProcessorRegistry processors{};
{ {
@@ -2,9 +2,11 @@
#include <csignal> #include <csignal>
#include <exception> #include <exception>
#include <iostream> #include <iostream>
#include <memory>
#include <string> #include <string>
#include "data_processor.hpp" #include "data_processor.hpp"
#include "locator/tcp_server.hpp"
#include "run_config.hpp" #include "run_config.hpp"
#include "shm_ring.hpp" #include "shm_ring.hpp"
@@ -21,6 +23,8 @@ void signal_handler(int /*signal*/) {
void install_signal_handlers() { void install_signal_handlers() {
std::signal(SIGINT, signal_handler); std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, 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 { [[nodiscard]] auto read_config_path(int argc, char** argv) -> std::string {
@@ -34,6 +38,21 @@ void install_signal_handlers() {
return config_path; 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 } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
@@ -54,11 +73,14 @@ int main(int argc, char** argv) {
config.rings.results.slot_size_bytes config.rings.results.slot_size_bytes
); );
auto locator_server = start_locator_server(config);
radar::processing::DataProcessor processor( radar::processing::DataProcessor processor(
config, config,
preprocessed_ring, preprocessed_ring,
results_ring, results_ring,
radar::processing::create_default_processors() radar::processing::create_default_processors(),
locator_server.get()
); );
processor.run(g_stop_requested); processor.run(g_stop_requested);
return 0; 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>()); 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 (const auto found = root.find("reprocess_current_result"); found != root.end()) {
if (!found->is_boolean()) { if (!found->is_boolean()) {
throw std::runtime_error("processing.reprocess_current_result must be bool"); 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 <algorithm>
#include <array> #include <array>
#include <chrono>
#include <cmath> #include <cmath>
#include <complex>
#include <exception> #include <exception>
#include <random>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <stdexcept> #include <stdexcept>
#include <thread>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -19,6 +23,60 @@ namespace {
constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U; constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
constexpr auto kNativeSweepResponseTimeout = std::chrono::milliseconds(1500); 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 { [[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool {
constexpr std::array<std::string_view, 5> kRetryableSubstrings = { constexpr std::array<std::string_view, 5> kRetryableSubstrings = {
"Timeout waiting for expected LibreVNA packet type", "Timeout waiting for expected LibreVNA packet type",
@@ -109,34 +167,82 @@ auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
} }
auto LibreVnaMinimalDriver::acquire_mock() -> 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{}; SweepTrace trace{};
trace.frequency_hz.reserve(settings_.sweep.points); trace.frequency_hz.reserve(settings_.sweep.points);
trace.s11.reserve(settings_.sweep.points); trace.s11.reserve(settings_.sweep.points);
trace.s21.reserve(settings_.sweep.points); trace.s21.reserve(settings_.sweep.points);
const auto span_hz = settings_.sweep.stop_hz - settings_.sweep.start_hz; const float 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 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) { for (std::uint32_t point = 0; point < settings_.sweep.points; ++point) {
const auto ratio = static_cast<float>(point) / denominator; const float ratio = static_cast<float>(point) / denominator;
const auto frequency_hz = settings_.sweep.start_hz + span_hz * ratio; const float 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)) + const float frequency_scale = frequency_hz / kAttenuationReferenceHz;
static_cast<float>(sweep_index_) * 0.05F;
const auto envelope = 0.6F + 0.4F * std::sin(0.5F * phase);
ipc::Complex32 sample{}; std::complex<float> s21_total{0.0F, 0.0F};
sample.re = envelope * std::cos(phase); std::complex<float> s11_total{0.0F, 0.0F};
sample.im = envelope * std::sin(phase);
const auto reflection_phase = 0.7F * phase + 0.35F; for (const auto& target : kMockTargets) {
const auto reflection_envelope = 0.15F + 0.1F * std::cos(0.25F * phase); const float range_m = target.range_m + range_drift_m;
ipc::Complex32 reflection{}; // Round-trip phase: 2π·f·(2R/v).
reflection.re = reflection_envelope * std::cos(reflection_phase); const float round_trip_phase =
reflection.im = reflection_envelope * std::sin(reflection_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.frequency_hz.push_back(frequency_hz);
trace.s11.push_back(reflection); trace.s11.push_back({.re = s11_total.real(), .im = s11_total.imag()});
trace.s21.push_back(sample); 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; 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.gui.preprocess_dialog import PreprocessDialog
from python_app.models.dataset_model import ResultCollection, SweepCollection from python_app.models.dataset_model import ResultCollection, SweepCollection
from python_app.models.gui_profile_model import GuiProfileModel 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.config_writer import ConfigWriter
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter 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.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
from python_app.orchestration.process_supervisor import ProcessSupervisor from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.shm_reader import ShmRingReader from python_app.orchestration.shm_reader import ShmRingReader
@@ -83,7 +82,22 @@ class AppWindow(
self._supervisor = ProcessSupervisor(self._project_root) self._supervisor = ProcessSupervisor(self._project_root)
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json") self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.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: def _init_config_profile_state(self) -> None:
"""Resolve startup profile path, load active profile, and queue fallback notices.""" """Resolve startup profile path, load active profile, and queue fallback notices."""
@@ -112,7 +126,6 @@ class AppWindow(
"INFO", "INFO",
f"Loaded legacy run config without GUI defaults: {active_profile_path}", 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) self._remember_active_profile_path(active_profile_path, startup=True)
def _init_reader_handles(self) -> None: def _init_reader_handles(self) -> None:
@@ -217,60 +230,13 @@ class AppWindow(
self._log(f"Active config profile: {self._active_profile_path}") self._log(f"Active config profile: {self._active_profile_path}")
self._refresh_preprocess_summary_labels() self._refresh_preprocess_summary_labels()
self._apply_initial_radar_limits() self._apply_initial_radar_limits()
self._start_locator_service()
self._on_processing_mode_changed(self._processing_mode.currentText()) self._on_processing_mode_changed(self._processing_mode.currentText())
self._write_live_processing_config() 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._timer.start()
self._maybe_auto_start_pipeline() 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: def _resolve_startup_profile_path(self) -> Path:
"""Resolve active profile path from session-state or root fallback path.""" """Resolve active profile path from session-state or root fallback path."""
env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip() env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
@@ -300,13 +266,37 @@ class AppWindow(
return profile_path return profile_path
def _maybe_auto_start_pipeline(self) -> None: def _maybe_auto_start_pipeline(self) -> None:
"""Schedule pipeline start when requested by launcher environment.""" """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"}: 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 return
self._log("Auto-start requested by launcher.") 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) 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: def _normalize_profile_path(self, path: Path) -> Path:
"""Return normalized absolute profile path.""" """Return normalized absolute profile path."""
return path.expanduser().resolve(strict=False) return path.expanduser().resolve(strict=False)
@@ -494,6 +484,8 @@ class AppWindow(
def _show_error(self, message: str, *, details: str | None = None) -> None: def _show_error(self, message: str, *, details: str | None = None) -> None:
"""Log and present an error in a modal dialog with optional detail text.""" """Log and present an error in a modal dialog with optional detail text."""
self._log_error(message, details=details) self._log_error(message, details=details)
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
return
dialog = QMessageBox(self) dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Critical) dialog.setIcon(QMessageBox.Icon.Critical)
dialog.setWindowTitle("Error") dialog.setWindowTitle("Error")
@@ -505,6 +497,8 @@ class AppWindow(
def _show_exception(self, context: str, exc: Exception) -> None: def _show_exception(self, context: str, exc: Exception) -> None:
"""Log full exception details and show modal dialog with expandable traceback.""" """Log full exception details and show modal dialog with expandable traceback."""
message, details = self._log_exception(context, exc, level="ERROR") message, details = self._log_exception(context, exc, level="ERROR")
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
return
dialog = QMessageBox(self) dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Critical) dialog.setIcon(QMessageBox.Icon.Critical)
dialog.setWindowTitle("Error") dialog.setWindowTitle("Error")
@@ -520,9 +514,6 @@ class AppWindow(
self._abort_capture_sequence(resume_pipeline=False) self._abort_capture_sequence(resume_pipeline=False)
# 2) Stop all managed processes/readers. # 2) Stop all managed processes/readers.
self._stop_all_processes() 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. # 3) Close auxiliary dialog windows.
if self._preprocess_dialog is not None: if self._preprocess_dialog is not None:
self._preprocess_dialog.close() self._preprocess_dialog.close()
@@ -89,6 +89,9 @@ class AppWindowLiveProcessingMixin:
gpr_background_mean_count=gpr_background_mean_count, gpr_background_mean_count=gpr_background_mean_count,
gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), 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_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), reprocess_current_result=bool(reprocess_current_result),
history_command_seq=int(self._history_command_seq), history_command_seq=int(self._history_command_seq),
history_command=str(history_command), history_command=str(history_command),
@@ -140,6 +143,14 @@ class AppWindowLiveProcessingMixin:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update live processing settings", exc) 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: def _on_gpr_visual_settings_changed(self, *_args) -> None:
"""Redraw current GPR result using updated GUI-only render settings.""" """Redraw current GPR result using updated GUI-only render settings."""
if not self._is_gpr_processing_mode(self._processing_mode.currentText()): 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) self._show_exception("Failed to update GPR render settings", exc)
def _on_gpr_locator_threshold_changed(self, *_args) -> None: 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() self._on_gpr_visual_settings_changed()
if not self._is_gpr_processing_mode(self._processing_mode.currentText()): if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
return return
try: try:
self._publish_locator_snapshot_from_latest_result() self._write_live_processing_config()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update locator GPR threshold", exc) self._show_exception("Failed to update locator GPR threshold", exc)
def _on_gpr_locator_window_changed(self, *_args) -> None: 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() self._on_gpr_visual_settings_changed()
if not self._is_gpr_processing_mode(self._processing_mode.currentText()): if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
return return
try: try:
self._publish_locator_snapshot_from_latest_result() self._write_live_processing_config()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update locator GPR window", exc) self._show_exception("Failed to update locator GPR window", exc)
@@ -205,10 +216,6 @@ class AppWindowLiveProcessingMixin:
self._set_plot_mode(mode) self._set_plot_mode(mode)
self._set_processing_mode_page(mode) self._set_processing_mode_page(mode)
self._on_processing_live_settings_changed() 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": if mode == "pass_through":
self._log( self._log(
"Processing mode selected: pass_through " "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_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_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_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s))
self._legacy_gpr_ignore_socket_speed_enabled.setChecked( ignore_socket_speed_enabled = bool(
bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled) 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_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
self._legacy_gpr_background_subtract_enabled.setChecked( self._legacy_gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.background_subtract_enabled) bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
@@ -494,7 +496,6 @@ class AppWindowConfigProfileIOMixin:
self._apply_initial_radar_limits() self._apply_initial_radar_limits()
if self._preprocess_dialog is not None: if self._preprocess_dialog is not None:
self._refresh_sets() self._refresh_sets()
self._reload_locator_service_from_config()
self._on_processing_mode_changed(gui_state.processing.selected_mode) self._on_processing_mode_changed(gui_state.processing.selected_mode)
self._update_history_indicator() self._update_history_indicator()
self._remember_active_profile_path(profile_path) self._remember_active_profile_path(profile_path)
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from python_app.hardware_full.librevna_service import LibreVnaService 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 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) return self._apply_radar_limits_to_ui(None)
if config.is_multi_device: if config.is_multi_device:
radar_service = LibreVnaService(serial=config.radar.serial or None) radar_service = LibreVnaService(serial=config.radar.serial or None)
elif config.is_matrix_radar:
radar_service = create_matrix_radar_service(config)
else: else:
radar_service = create_single_radar_service(config) 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.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path) config.runtime.processing_live_config_path = str(self._live_config_writer.path)
if config.is_multi_device: if config.is_matrix_radar:
if len(config.radar.multi_device.slave_serials) != 2: 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") raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
config.apply_device_model_constraints() config.apply_device_model_constraints()
else: else:
@@ -4,7 +4,6 @@ from __future__ import annotations
import time import time
from PyQt6.QtCore import QSignalBlocker
from python_app.gui.runtime.constraints import validate_processing_mode_constraints 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 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.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel 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 ( from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS, PREPROCESS_ASSET_SPECS,
REQUIRED_PREPROCESS_ASSET_KEYS, REQUIRED_PREPROCESS_ASSET_KEYS,
@@ -42,8 +40,18 @@ class AppWindowPipelineMixin:
) )
return return
if self._supervisor.is_running(): 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 return
self._log("Stopping continuous pipeline before single capture")
self._stop_run()
try: try:
processor_was_running = self._supervisor.is_processor_running() processor_was_running = self._supervisor.is_processor_running()
@@ -198,6 +206,9 @@ class AppWindowPipelineMixin:
if config.is_multi_device: if config.is_multi_device:
self._log("Multi-device raw producer will configure all LibreVNA devices") self._log("Multi-device raw producer will configure all LibreVNA devices")
return return
if config.is_matrix_radar:
self._log("Matrix raw producer will configure the matrix radar")
return
if config.is_kamil_adc: if config.is_kamil_adc:
if apply_kamil_adc_laser_control(config): if apply_kamil_adc_laser_control(config):
self._log("Kamil ADC laser_control applied via Apply Radar") self._log("Kamil ADC laser_control applied via Apply Radar")
@@ -280,8 +291,6 @@ class AppWindowPipelineMixin:
self._log_error(report.format()) self._log_error(report.format())
try: try:
self._drain_locator_speed_updates()
self._drain_locator_log_updates()
if self._raw_reader is not None: if self._raw_reader is not None:
self._read_all_raw() self._read_all_raw()
self._read_all_preprocessed() self._read_all_preprocessed()
@@ -294,7 +303,14 @@ class AppWindowPipelineMixin:
return return
return return
if result_latest is not None:
render_started_ns = time.monotonic_ns()
self._draw_preferred_collection(result_latest=result_latest) 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 except Exception as exc: # noqa: BLE001
signature = (type(exc).__name__, str(exc)) signature = (type(exc).__name__, str(exc))
if self._last_reader_error_signature == signature: if self._last_reader_error_signature == signature:
@@ -347,6 +363,11 @@ class AppWindowPipelineMixin:
break break
self._raw_history.append(collection) self._raw_history.append(collection)
latest = 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 self._single_capture_active and self._single_capture_start_ns is not None:
if collection.monotonic_ns >= self._single_capture_start_ns: if collection.monotonic_ns >= self._single_capture_start_ns:
self._single_capture_seen_raw = True self._single_capture_seen_raw = True
@@ -373,13 +394,9 @@ class AppWindowPipelineMixin:
collection = self._result_reader.pop_result_collection() collection = self._result_reader.pop_result_collection()
if collection is None: if collection is None:
break break
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
if record_result_history(self._result_history, collection): if record_result_history(self._result_history, collection):
latest = 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 return latest
def _drain_rings_once_for_history(self) -> None: def _drain_rings_once_for_history(self) -> None:
@@ -484,60 +501,3 @@ class AppWindowPipelineMixin:
self._live_processing_config(), 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( owner._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(legacy_gpr_defaults.ignore_socket_speed_enabled) 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 = QDoubleSpinBox()
owner._legacy_gpr_look_angle_deg.setDecimals(2) 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_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_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_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_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_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_mean_count.valueChanged.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 from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import signal
import sys import sys
import pyqtgraph as pg import pyqtgraph as pg
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
# Ensure imports are resolved when started as a script. # 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 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: def main() -> int:
"""Run Qt event loop and show main radar control window.""" """Run Qt event loop and show main radar control window."""
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -24,6 +59,9 @@ def main() -> int:
# PyQtGraph foreground controls axis lines, tick text, labels, and titles. # PyQtGraph foreground controls axis lines, tick text, labels, and titles.
pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#ffffff") pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#ffffff")
window = AppWindow(PROJECT_ROOT) window = AppWindow(PROJECT_ROOT)
if _is_headless():
_install_unix_signal_handlers(app, window)
else:
window.showMaximized() window.showMaximized()
return app.exec() return app.exec()
@@ -59,9 +59,11 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
raise RuntimeError("SN9000 requires radar.driver_mode='native'") raise RuntimeError("SN9000 requires radar.driver_mode='native'")
from python_app.hardware_full.sn9000_service import Sn9000Service from python_app.hardware_full.sn9000_service import Sn9000Service
visa_library = config.radar.visa_library or "@ivi"
return Sn9000Service( return Sn9000Service(
host=config.radar.remote_host, host=config.radar.remote_host,
port=config.radar.remote_port, port=config.radar.remote_port,
visa_library=visa_library,
) )
raise RuntimeError(f"Unsupported matrix radar model: {model}") raise RuntimeError(f"Unsupported matrix radar model: {model}")
+42 -3
View File
@@ -60,8 +60,6 @@ class Sn9000Service:
if self.timeout_ms <= 0: if self.timeout_ms <= 0:
raise ValueError("SN9000 timeout_ms must be > 0") raise ValueError("SN9000 timeout_ms must be > 0")
self.visa_library = str(self.visa_library).strip() or "@ivi" 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 @property
def resource(self) -> str: def resource(self) -> str:
@@ -146,6 +144,10 @@ class Sn9000Service:
return { return {
"min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")), "min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")),
"max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")), "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?"))), "max_points": int(float(instrument.query("SERV:SWE:POIN?"))),
"min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")), "min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")),
"max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")), "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]: def _query_sweep_s_parameters(self, points: int) -> dict[str, np.ndarray]:
instrument = self._require_instrument() 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) data_queries = ";".join(f":SENS:DATA:CORR? {name}" for name in _S_PARAMETER_QUERY_ORDER)
instrument.write(f"TRIG:SING;*OPC?;{data_queries}") instrument.write(f"TRIG:SING;*OPC?;{data_queries}")
opc_token = self._read_ascii_token() opc_token = self._read_ascii_token()
if opc_token != "1": if opc_token != "1":
raise RuntimeError(f"SN9000 sweep returned unexpected *OPC? response: {opc_token!r}") 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: for parameter_name in _S_PARAMETER_QUERY_ORDER:
interleaved = self._read_float32_block(f"SENS:DATA:CORR? {parameter_name}", points * 2) interleaved = self._read_float32_block(f"SENS:DATA:CORR? {parameter_name}", points * 2)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved) complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values 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]: def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]:
frequency_hz = self._require_frequency_axis() frequency_hz = self._require_frequency_axis()
traces: list[TraceData] = [] traces: list[TraceData] = []
@@ -286,8 +307,26 @@ class Sn9000Service:
f"SN9000 response for {context!r} returned {array.size} float32 values, " f"SN9000 response for {context!r} returned {array.size} float32 values, "
f"expected {expected_values}" f"expected {expected_values}"
) )
self._drain_trailing_terminators()
return array 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: def _read_response_bytes(self, count: int) -> bytes:
instrument = self._require_instrument() instrument = self._require_instrument()
data = instrument.read_bytes(count, break_on_termchar=False) data = instrument.read_bytes(count, break_on_termchar=False)
+3
View File
@@ -80,5 +80,8 @@ class ResultCollection:
collection_id: int collection_id: int
monotonic_ns: 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) collection_payloads: list[ResultPayload] = field(default_factory=list)
blocks: list[ResultBlock] = 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.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.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.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.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)) 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, "remote_port": model.radar.remote_port,
"driver_mode": model.radar.driver_mode, "driver_mode": model.radar.driver_mode,
"mock_signal_hz": model.radar.mock_signal_hz, "mock_signal_hz": model.radar.mock_signal_hz,
"visa_library": model.radar.visa_library,
"multi_device": { "multi_device": {
"slave_serials": list(model.radar.multi_device.slave_serials), "slave_serials": list(model.radar.multi_device.slave_serials),
"force_external_reference": model.radar.multi_device.force_external_reference, "force_external_reference": model.radar.multi_device.force_external_reference,
+1
View File
@@ -103,6 +103,7 @@ class RadarModel:
remote_port: int = 50209 remote_port: int = 50209
driver_mode: str = "mock" driver_mode: str = "mock"
mock_signal_hz: float = 1_000_000.0 mock_signal_hz: float = 1_000_000.0
visa_library: str = ""
sweep: RadarSweepModel = field(default_factory=RadarSweepModel) sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel) multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel) 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 __future__ import annotations
from datetime import datetime
from typing import Any
import numpy as np import numpy as np
from python_app.models.dataset_model import ResultCollection, ResultPayload 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 centers[:, :3]
return np.zeros((0, 3), dtype=np.float32) 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_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True gpr_remove_sidelobe_objects_enabled: bool = True
gpr_imaging_plane_y_m: float = 0.0 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 reprocess_current_result: bool = True
history_command_seq: int = 0 history_command_seq: int = 0
history_command: str = "none" history_command: str = "none"
@@ -95,6 +101,9 @@ class ProcessingLiveConfig:
"gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_background_mean_count": int(self.gpr_background_mean_count),
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), "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_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), "reprocess_current_result": bool(self.reprocess_current_result),
"history_command_seq": int(self.history_command_seq), "history_command_seq": int(self.history_command_seq),
"history_command": str(self.history_command), "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 RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050 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: 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() collection_id = cursor.read_u64()
monotonic_ns = cursor.read_u64() monotonic_ns = cursor.read_u64()
processing_duration_ns = cursor.read_u64()
collection_payload_count = cursor.read_u32() collection_payload_count = cursor.read_u32()
block_count = cursor.read_u32() block_count = cursor.read_u32()
@@ -163,6 +164,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
return ResultCollection( return ResultCollection(
collection_id=collection_id, collection_id=collection_id,
monotonic_ns=monotonic_ns, monotonic_ns=monotonic_ns,
processing_duration_ns=processing_duration_ns,
collection_payloads=collection_payloads, collection_payloads=collection_payloads,
blocks=blocks, blocks=blocks,
) )
@@ -75,8 +75,8 @@ def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
config = RunConfigModel.from_dict(config_payload) config = RunConfigModel.from_dict(config_payload)
if config.radar.driver_mode != "native": if config.radar.driver_mode != "native":
return "Radar pre-configuration skipped (mock mode)." return "Radar pre-configuration skipped (mock mode)."
if config.is_multi_device: if config.is_matrix_radar:
return "Radar pre-configuration skipped (multi-device producer config)." return "Radar pre-configuration skipped (matrix radar producer config)."
radar_service = create_single_radar_service(config) radar_service = create_single_radar_service(config)
if not getattr(radar_service, "driver_available", True): 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 RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050 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: def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
@@ -109,10 +109,11 @@ def serialize_result_collection(collection: ResultCollection) -> bytes:
buffer = bytearray() buffer = bytearray()
buffer.extend( buffer.extend(
struct.pack( struct.pack(
"<IQQII", "<IQQQII",
RESULT_MAGIC, RESULT_MAGIC,
collection.collection_id, collection.collection_id,
collection.monotonic_ns, collection.monotonic_ns,
int(collection.processing_duration_ns),
len(collection.collection_payloads), len(collection.collection_payloads),
len(collection.blocks), len(collection.blocks),
) )
+2 -2
View File
@@ -19,9 +19,9 @@ def capture_calibration_set(
median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT, median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
) -> tuple[str, SweepCollection]: ) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as calibration set.""" """Capture all switch combinations and persist them as calibration set."""
if config.is_multi_device: if config.is_matrix_radar:
raise RuntimeError( 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 " "Use the sequential preprocess capture flow so each virtual combo can be connected through "
"and captured explicitly." "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.storage.npz_store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import ( from python_app.workflows.sequential_capture_workflow import (
MULTI_DEVICE_MANUAL_CAPTURE_KINDS, MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
SequentialCaptureState, SequentialCaptureState,
combine_collections_via_median, combine_collections_via_median,
combine_traces_via_median, combine_traces_via_median,
@@ -75,8 +75,9 @@ class MultiRadarSequentialCaptureSession:
self._radar_variants = list(radar_variants) self._radar_variants = list(radar_variants)
self._median_sweep_count = int(median_sweep_count) self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = base_config.is_matrix_radar self._is_matrix_radar = base_config.is_matrix_radar
self._is_multi_device = base_config.is_multi_device self._manual_matrix_radar_capture = (
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = ( self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos() RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar if self._is_matrix_radar
@@ -169,7 +170,7 @@ class MultiRadarSequentialCaptureSession:
set_name=self._set_name, set_name=self._set_name,
captured_count=( captured_count=(
self._next_index 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) else len(self._captured_batches)
), ),
total_count=len(self._combos), total_count=len(self._combos),
@@ -177,7 +178,7 @@ class MultiRadarSequentialCaptureSession:
can_undo=bool(self._captured_batches), can_undo=bool(self._captured_batches),
is_complete=self.is_complete(), is_complete=self.is_complete(),
variant_count=len(self._radar_variants), 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: def capture_current_combo(self) -> MultiRadarCaptureBatch:
@@ -205,7 +206,7 @@ class MultiRadarSequentialCaptureSession:
f"Matrix radar variant {variant.display_name} returned no traces" f"Matrix radar variant {variant.display_name} returned no traces"
) )
collections.append(collection) 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] per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces) trace = combine_traces_via_median(per_sweep_traces)
pending_traces_by_radar_key[variant.radar_key] = [trace] pending_traces_by_radar_key[variant.radar_key] = [trace]
@@ -251,7 +252,7 @@ class MultiRadarSequentialCaptureSession:
variant_labels=tuple(variant_labels), variant_labels=tuple(variant_labels),
) )
self._captured_batches.append(batch) 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) self._next_index = len(self._combos)
else: else:
self._next_index += 1 self._next_index += 1
@@ -264,7 +265,7 @@ class MultiRadarSequentialCaptureSession:
if not self._captured_batches or self._next_index <= 0: if not self._captured_batches or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo") 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] removed_batch = self._captured_batches[-1]
for variant in self._radar_variants: for variant in self._radar_variants:
traces = self._traces_by_radar_key[variant.radar_key] 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.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config 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 DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5
@@ -58,8 +58,9 @@ class SequentialCaptureSession:
self._set_name = set_name self._set_name = set_name
self._median_sweep_count = int(median_sweep_count) self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = config.is_matrix_radar self._is_matrix_radar = config.is_matrix_radar
self._is_multi_device = config.is_multi_device self._manual_matrix_radar_capture = (
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = ( self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos() RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar if self._is_matrix_radar
@@ -148,7 +149,7 @@ class SequentialCaptureSession:
current_combo=current_combo, current_combo=current_combo,
can_undo=bool(self._traces), can_undo=bool(self._traces),
is_complete=self.is_complete(), 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: def capture_current_combo(self) -> TraceData:
@@ -166,7 +167,7 @@ class SequentialCaptureSession:
if not collection.traces: if not collection.traces:
raise RuntimeError("Matrix radar capture returned no traces") raise RuntimeError("Matrix radar capture returned no traces")
collections.append(collection) 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] per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces) trace = combine_traces_via_median(per_sweep_traces)
self._traces.append(trace) self._traces.append(trace)
@@ -208,7 +209,7 @@ class SequentialCaptureSession:
if not self._traces or self._next_index <= 0: if not self._traces or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo") 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): if len(self._traces) != len(self._combos):
raise RuntimeError("Capture session state is inconsistent; matrix radar trace matrix is incomplete") raise RuntimeError("Capture session state is inconsistent; matrix radar trace matrix is incomplete")
removed_trace = self._traces[-1] removed_trace = self._traces[-1]
+8 -12
View File
@@ -1,22 +1,18 @@
{ {
"radar": { "radar": {
"model": "librevna", "model": "sn9000",
"serial": "", "remote_host": "192.168.2.102",
"driver_mode": "mock", "remote_port": 4880,
"mock_signal_hz": 5000000.0, "driver_mode": "native",
"multi_device": { "visa_library": "@py",
"slave_serials": [],
"force_external_reference": false,
"recovery_attempts": 3
},
"sweep": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201, "points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 10000.0,
"stimulus_power_dbm": -10.0 "stimulus_power_dbm": -10.0
} }
}, },
"switches": { "switches": {
"port1": { "port1": {
"name": "port1", "name": "port1",
+28 -2
View File
@@ -16,6 +16,7 @@ CLEAN_SHM=0
KAMIL_ADC_MODE=0 KAMIL_ADC_MODE=0
AUTO_START=0 AUTO_START=0
PRODUCER_ONLY=0 PRODUCER_ONLY=0
HEADLESS=0
print_usage() { print_usage() {
cat <<'EOF' cat <<'EOF'
@@ -25,6 +26,10 @@ Options:
--kamil-adc Use the Raspberry Pi Kamil ADC profile --kamil-adc Use the Raspberry Pi Kamil ADC profile
--profile PATH Use a specific GUI/run config profile --profile PATH Use a specific GUI/run config profile
--auto-start Start the GUI pipeline automatically after launch --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 --producer-only Run only the raw producer selected by the profile
--skip-build Skip C++ build step --skip-build Skip C++ build step
--build-only Build C++ binaries and exit --build-only Build C++ binaries and exit
@@ -59,6 +64,10 @@ parse_args() {
--auto-start) --auto-start)
AUTO_START=1 AUTO_START=1
;; ;;
--headless)
HEADLESS=1
AUTO_START=1
;;
--producer-only) --producer-only)
PRODUCER_ONLY=1 PRODUCER_ONLY=1
;; ;;
@@ -140,11 +149,14 @@ ensure_python_dependencies() {
exit 1 exit 1
fi 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..." echo "[start.sh] Installing Python dependencies into virtual environment..."
"${VENV_PIP}" install --upgrade pip "${VENV_PIP}" install --upgrade pip
"${VENV_PIP}" install -r "${REQUIREMENTS_FILE}" "${VENV_PIP}" install -r "${REQUIREMENTS_FILE}"
fi
if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then 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 echo "Required Python dependencies are still unavailable in virtual environment: ${PROJECT_ROOT}/.venv" >&2
@@ -234,6 +246,10 @@ EOF
} }
build_cpp_binaries() { 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 local jobs
jobs="${BUILD_JOBS:-$(nproc)}" jobs="${BUILD_JOBS:-$(nproc)}"
echo "[start.sh] Building C++ binaries (jobs=${jobs})..." echo "[start.sh] Building C++ binaries (jobs=${jobs})..."
@@ -265,6 +281,16 @@ run_gui() {
export RADAR_SYSTEM_AUTO_START=1 export RADAR_SYSTEM_AUTO_START=1
echo "[start.sh] GUI auto-start is enabled." echo "[start.sh] GUI auto-start is enabled."
fi 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..." echo "[start.sh] Launching GUI..."
exec "${PYTHON_CMD}" "${GUI_ENTRY}" exec "${PYTHON_CMD}" "${GUI_ENTRY}"