Merge branch 'laser-temp-monitoring' into feature/switched-matrix-radar
This commit is contained in:
@@ -8,6 +8,7 @@ build*
|
|||||||
# C extensions
|
# C extensions
|
||||||
python_app/data*
|
python_app/data*
|
||||||
*.so
|
*.so
|
||||||
|
*.ipynb
|
||||||
*.npy
|
*.npy
|
||||||
snapshots/
|
snapshots/
|
||||||
test_results*
|
test_results*
|
||||||
|
|||||||
@@ -65,17 +65,36 @@ PROCESSOR_SOURCES := \
|
|||||||
# from the vendored headers alone and only needs those libraries present at run
|
# from the vendored headers alone and only needs those libraries present at run
|
||||||
# time. Built on demand (not part of `all`) for radar.model == kamil_adc.
|
# time. Built on demand (not part of `all`) for radar.model == kamil_adc.
|
||||||
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
|
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
|
||||||
KAMIL_COLLECTOR_INCLUDES := -I$(KAMIL_COLLECTOR_DIR)/include -I$(KAMIL_COLLECTOR_DIR)/vendor/lcard
|
# The collector now also drives the RF switches itself (switch-aware mode), so it
|
||||||
|
# reuses the orchestrator's switch drivers and the shared run_config loader, which
|
||||||
|
# pull in the common config/ipc/locator headers and the vendored nlohmann/json.
|
||||||
|
KAMIL_COLLECTOR_INCLUDES := \
|
||||||
|
-I$(KAMIL_COLLECTOR_DIR)/include \
|
||||||
|
-I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \
|
||||||
|
-Idata_acq_and_processing/common_cpp/ipc/include \
|
||||||
|
-Idata_acq_and_processing/common_cpp/config/include \
|
||||||
|
-Idata_acq_and_processing/sweep_orchestrator/device_drivers/interfaces \
|
||||||
|
-Idata_acq_and_processing/sweep_orchestrator/device_drivers/switches \
|
||||||
|
-Idata_acq_and_processing/processing/locator/include \
|
||||||
|
-Idata_acq_and_processing/third_party
|
||||||
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
||||||
|
# Collector sources, including the shared drivers/config loader it reuses. These
|
||||||
|
# are compiled into a private build/kamil/ tree (see rule below) so they never
|
||||||
|
# collide with the orchestrator's objects, which use different compile flags.
|
||||||
KAMIL_COLLECTOR_SOURCES := \
|
KAMIL_COLLECTOR_SOURCES := \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
|
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp
|
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
|
||||||
|
data_acq_and_processing/common_cpp/config/src/run_config.cpp \
|
||||||
|
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
|
||||||
|
data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp \
|
||||||
|
data_acq_and_processing/sweep_orchestrator/device_drivers/switches/hmc349a_minimal_driver.cpp
|
||||||
|
|
||||||
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))
|
||||||
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
|
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
|
||||||
KAMIL_COLLECTOR_OBJS := $(addprefix $(BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o))
|
KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil
|
||||||
|
KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(notdir $(KAMIL_COLLECTOR_SOURCES:.cpp=.o)))
|
||||||
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
|
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
|
||||||
|
|
||||||
TARGETS := \
|
TARGETS := \
|
||||||
@@ -101,11 +120,15 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS)
|
|||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS)
|
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS)
|
||||||
|
|
||||||
# The collector is self-contained: compile its objects with only the vendored
|
# The collector compiles into its own build/kamil/ tree with collector-only
|
||||||
# L-Card headers (no project/VISA includes) by overriding the generic rule's
|
# includes (no VISA), so the shared driver/config sources it reuses never collide
|
||||||
# variables for these objects, then link with dlopen/openpty support.
|
# with the orchestrator's objects of the same name. Basenames are unique, so a
|
||||||
$(KAMIL_COLLECTOR_OBJS): INCLUDES := $(KAMIL_COLLECTOR_INCLUDES)
|
# vpath lets one pattern rule find every source.
|
||||||
$(KAMIL_COLLECTOR_OBJS): VISA_CXXFLAGS :=
|
vpath %.cpp $(sort $(dir $(KAMIL_COLLECTOR_SOURCES)))
|
||||||
|
|
||||||
|
$(KAMIL_BUILD_DIR)/%.o: %.cpp
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
$(CXX) $(CXXFLAGS) $(KAMIL_COLLECTOR_INCLUDES) -c $< -o $@
|
||||||
|
|
||||||
$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS)
|
$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS)
|
||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ auto ShmRing::open_or_create(
|
|||||||
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
|
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
|
||||||
|
|
||||||
if (magic_ok && version_ok && geometry_ok) {
|
if (magic_ok && version_ok && geometry_ok) {
|
||||||
// Fix #50: the requested mapped_size matched the header geometry, but the
|
// the requested mapped_size matched the header geometry, but the
|
||||||
// backing file may have been created undersized by another process. Confirm
|
// backing file may have been created undersized by another process. Confirm
|
||||||
// st_size covers the geometry before trusting the mapping.
|
// st_size covers the geometry before trusting the mapping.
|
||||||
struct stat info {};
|
struct stat info {};
|
||||||
@@ -257,7 +257,7 @@ auto ShmRing::open_existing(const std::string& name) -> ShmRing {
|
|||||||
if (header->version != kRingVersion) {
|
if (header->version != kRingVersion) {
|
||||||
throw std::runtime_error("Shared memory ring version mismatch for " + name);
|
throw std::runtime_error("Shared memory ring version mismatch for " + name);
|
||||||
}
|
}
|
||||||
// Fix #50: ensure the mapping actually spans every slot the header describes.
|
// ensure the mapping actually spans every slot the header describes.
|
||||||
validate_geometry(*header, mapped_size, name);
|
validate_geometry(*header, mapped_size, name);
|
||||||
|
|
||||||
ShmRing ring{};
|
ShmRing ring{};
|
||||||
@@ -411,7 +411,7 @@ void ShmRing::validate_name(const std::string& name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
|
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
|
||||||
// Fix #50: derive the expected size from the header's own geometry fields and
|
// derive the expected size from the header's own geometry fields and
|
||||||
// require the real mapping to cover it. A bogus capacity/slot_size or a truncated
|
// require the real mapping to cover it. A bogus capacity/slot_size or a truncated
|
||||||
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
|
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
|
||||||
const std::uint32_t capacity = header.capacity;
|
const std::uint32_t capacity = header.capacity;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <memory>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "shared_types.hpp"
|
||||||
|
#include "switch_driver.hpp"
|
||||||
|
|
||||||
|
namespace radar::kamil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Drives the input/output RF switches in lock-step with sweep boundaries.
|
||||||
|
*
|
||||||
|
* The E-502 stream is free-running: sweeps are delimited by DI_SYN2 edges with a
|
||||||
|
* hardware idle gap between them. On every completed sweep we step to the next
|
||||||
|
* switch combination *during that gap*, so the next sweep starts already settled
|
||||||
|
* in the new state and no samples are lost.
|
||||||
|
*
|
||||||
|
* The one failure mode of doing this purely in software is the readout backlog:
|
||||||
|
* if, at the moment we observe a sweep's end, we are running so far behind real
|
||||||
|
* time that the toggle could not have landed within the gap, the upcoming sweep
|
||||||
|
* straddles the transition. We flag that sweep *dirty*; the consumer drops it and
|
||||||
|
* we re-take the same combination on the following sweep (which is then clean,
|
||||||
|
* because no transition happens during its gap). A clean sweep costs nothing; a
|
||||||
|
* miss costs exactly one extra sweep period for that one combination.
|
||||||
|
*
|
||||||
|
* The sequencer owns the two switch drivers and exposes only what the collector
|
||||||
|
* needs at a sweep boundary: which combination the upcoming sweep belongs to and
|
||||||
|
* whether it is expected to be clean. All timing policy lives here so the recv
|
||||||
|
* loop stays readable.
|
||||||
|
*/
|
||||||
|
class SwitchSequencer {
|
||||||
|
public:
|
||||||
|
struct Settings {
|
||||||
|
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||||
|
/// the sequencer entirely (the collector then streams a single passthrough
|
||||||
|
/// channel exactly as before).
|
||||||
|
std::vector<radar::ipc::ComboKey> combos{};
|
||||||
|
/// Guaranteed minimum inter-sweep idle window, in milliseconds. This is a
|
||||||
|
/// hardware property of the sweep generator; treat it as a lower bound.
|
||||||
|
double gap_ms = 10.0;
|
||||||
|
/// RF settle time required after a position change, in milliseconds.
|
||||||
|
double settle_ms = 1.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
SwitchSequencer(
|
||||||
|
Settings settings,
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> input_switch,
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> output_switch
|
||||||
|
)
|
||||||
|
: settings_(std::move(settings)),
|
||||||
|
input_switch_(std::move(input_switch)),
|
||||||
|
output_switch_(std::move(output_switch)) {}
|
||||||
|
|
||||||
|
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||||
|
|
||||||
|
/// Open both switch drivers and park at the first combination.
|
||||||
|
void open() {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output_switch_->open();
|
||||||
|
input_switch_->open();
|
||||||
|
index_ = 0;
|
||||||
|
applied_ = settings_.combos.front();
|
||||||
|
apply(applied_);
|
||||||
|
upcoming_dirty_ = false; // the first sweep is captured in a settled state
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Best-effort: teardown must never throw out of the collector's stop path.
|
||||||
|
try {
|
||||||
|
output_switch_->close();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
input_switch_->close();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combination the in-progress / upcoming sweep is captured under.
|
||||||
|
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||||
|
return settings_.combos[index_];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||||
|
/// transition and must be dropped by the consumer.
|
||||||
|
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Advance the sequence at a completed sweep's end (start of the gap).
|
||||||
|
*
|
||||||
|
* @param backlog_ms Estimated readout lag at this instant — how far behind
|
||||||
|
* real time we are, i.e. how late the toggle we issue now will land.
|
||||||
|
*
|
||||||
|
* If the sweep that just ended was clean we step to the next combination and
|
||||||
|
* toggle the switches now, inside the gap. If it was dirty we re-take the same
|
||||||
|
* combination (the switches are already there and long settled). The upcoming
|
||||||
|
* sweep is marked dirty only when a real transition happens *and* the backlog
|
||||||
|
* leaves no room for it to settle before the next sweep starts.
|
||||||
|
*/
|
||||||
|
void on_sweep_end(double backlog_ms) {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcoming_dirty_) {
|
||||||
|
// The sweep that just ended straddled a transition: re-take the same
|
||||||
|
// combination. The switches are already applied and settled, so the
|
||||||
|
// next sweep is clean without touching the hardware again.
|
||||||
|
upcoming_dirty_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
index_ = (index_ + 1U) % settings_.combos.size();
|
||||||
|
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||||
|
const bool position_changed = !(next == applied_);
|
||||||
|
if (position_changed) {
|
||||||
|
apply(next);
|
||||||
|
applied_ = next;
|
||||||
|
}
|
||||||
|
upcoming_dirty_ = position_changed && ((backlog_ms + settings_.settle_ms) > settings_.gap_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void apply(const radar::ipc::ComboKey& combo) {
|
||||||
|
// Output first, then input — same order the sweep orchestrator uses, so
|
||||||
|
// both acquisition paths drive an identical hardware sequence.
|
||||||
|
output_switch_->switch_to(combo.output_pos);
|
||||||
|
input_switch_->switch_to(combo.input_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings settings_;
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> input_switch_;
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> output_switch_;
|
||||||
|
std::size_t index_ = 0;
|
||||||
|
radar::ipc::ComboKey applied_{};
|
||||||
|
bool upcoming_dirty_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace radar::kamil
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
#include "capture_file_writer.h"
|
#include "capture_file_writer.h"
|
||||||
#include "tty_protocol_writer.h"
|
#include "tty_protocol_writer.h"
|
||||||
|
|
||||||
|
#include "switch_sequencer.h"
|
||||||
|
#include "run_config.hpp"
|
||||||
|
#include "h7992_minimal_driver.hpp"
|
||||||
|
#include "hmc349a_minimal_driver.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
@@ -113,6 +118,12 @@ struct Config {
|
|||||||
std::string live_html_path = "live_plot.html";
|
std::string live_html_path = "live_plot.html";
|
||||||
std::string live_json_path = "live_plot.json";
|
std::string live_json_path = "live_plot.json";
|
||||||
std::optional<std::string> tty_path;
|
std::optional<std::string> tty_path;
|
||||||
|
// When set, the collector reads switch and combo configuration from this
|
||||||
|
// run_config.json and drives the RF switches itself, one combination per
|
||||||
|
// sweep, tagging each emitted sweep with its combo (see SwitchSequencer).
|
||||||
|
std::optional<std::string> run_config_path;
|
||||||
|
double switch_gap_ms = 10.0;
|
||||||
|
double switch_settle_ms = 1.0;
|
||||||
bool di1_group_average = false;
|
bool di1_group_average = false;
|
||||||
bool do1_toggle_per_frame = false;
|
bool do1_toggle_per_frame = false;
|
||||||
bool do1_noise_subtract = false;
|
bool do1_noise_subtract = false;
|
||||||
@@ -479,6 +490,7 @@ void print_help(const char* exe_name) {
|
|||||||
<< " [di1:zero|trace|ignore]\n"
|
<< " [di1:zero|trace|ignore]\n"
|
||||||
<< " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n"
|
<< " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n"
|
||||||
<< " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n"
|
<< " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n"
|
||||||
|
<< " [config:run_config.json] [switch_gap_ms:10] [switch_settle_ms:1]\n"
|
||||||
<< " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n"
|
<< " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n"
|
||||||
<< " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n"
|
<< " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n"
|
||||||
<< " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n"
|
<< " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n"
|
||||||
@@ -514,6 +526,13 @@ void print_help(const char* exe_name) {
|
|||||||
<< " step_words:32768 -> input stream transfer step in 32-bit words\n"
|
<< " step_words:32768 -> input stream transfer step in 32-bit words\n"
|
||||||
<< " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n"
|
<< " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n"
|
||||||
<< " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n"
|
<< " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n"
|
||||||
|
<< " config:run_config.json -> switch-aware mode: read the input/output switch and combo\n"
|
||||||
|
<< " configuration from this run_config.json and drive the RF switches\n"
|
||||||
|
<< " in lock-step with sweeps; each sweep is tagged with its combo via a\n"
|
||||||
|
<< " 0x00C0 frame (input_pos, output_pos, dirty). Omit for standalone mode.\n"
|
||||||
|
<< " switch_gap_ms:10 -> guaranteed minimum inter-sweep idle window; lower bound used to decide\n"
|
||||||
|
<< " whether an in-gap switch lands before the next sweep starts\n"
|
||||||
|
<< " switch_settle_ms:1 -> RF settle time required after a switch position change\n"
|
||||||
<< " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n"
|
<< " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n"
|
||||||
<< " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n"
|
<< " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n"
|
||||||
<< " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n"
|
<< " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n"
|
||||||
@@ -793,6 +812,18 @@ Config parse_args(int argc, char** argv) {
|
|||||||
cfg.tty_path = arg.substr(4);
|
cfg.tty_path = arg.substr(4);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (starts_with(arg, "config:")) {
|
||||||
|
cfg.run_config_path = arg.substr(7);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (starts_with(arg, "switch_gap_ms:")) {
|
||||||
|
cfg.switch_gap_ms = parse_double(arg.substr(14), "switch_gap_ms");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (starts_with(arg, "switch_settle_ms:")) {
|
||||||
|
cfg.switch_settle_ms = parse_double(arg.substr(17), "switch_settle_ms");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
fail("Unknown argument: " + arg);
|
fail("Unknown argument: " + arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,6 +1234,10 @@ constexpr uint32_t kDo1TogglePeriodTicks = 2U;
|
|||||||
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
|
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
|
||||||
constexpr uint32_t kDo8HighTicks = 2U;
|
constexpr uint32_t kDo8HighTicks = 2U;
|
||||||
constexpr uint16_t kTtyMarkerDi8High = 0x00A8U;
|
constexpr uint16_t kTtyMarkerDi8High = 0x00A8U;
|
||||||
|
// Combo tag frame: [0x00C0, input_pos, output_pos, dirty]. Emitted right after a
|
||||||
|
// sweep-boundary frame to tell the consumer which switch combination the upcoming
|
||||||
|
// sweep belongs to, and whether it straddled a switch transition (dirty != 0).
|
||||||
|
constexpr uint16_t kTtyMarkerComboTag = 0x00C0U;
|
||||||
constexpr uint32_t kStreamInputAdcFlag = 0x80000000U;
|
constexpr uint32_t kStreamInputAdcFlag = 0x80000000U;
|
||||||
constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U;
|
constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U;
|
||||||
|
|
||||||
@@ -1802,6 +1837,55 @@ void print_device_info(const t_x502_info& info) {
|
|||||||
<< "MCU firmware: " << info.mcu_firmware_ver << "\n";
|
<< "MCU firmware: " << info.mcu_firmware_ver << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build one switch driver (native GPIO or mock) from a parsed switch config,
|
||||||
|
// reusing the exact driver implementations the sweep orchestrator uses so both
|
||||||
|
// acquisition paths drive identical hardware.
|
||||||
|
std::unique_ptr<radar::drivers::SwitchDriver> make_switch_driver(const radar::config::SwitchConfig& sc) {
|
||||||
|
if (sc.driver_kind == radar::config::SwitchDriverKind::HMC349A) {
|
||||||
|
return std::make_unique<radar::drivers::HMC349AMinimalDriver>(
|
||||||
|
radar::drivers::HMC349AMinimalDriverSettings{
|
||||||
|
.name = sc.name,
|
||||||
|
.mode = sc.driver_mode,
|
||||||
|
.positions = sc.positions,
|
||||||
|
.default_position = sc.default_position,
|
||||||
|
.gpio_chip = sc.gpio_chip,
|
||||||
|
.pin_a = sc.pin_a,
|
||||||
|
.invert_logic = sc.invert_logic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::make_unique<radar::drivers::H7992MinimalDriver>(
|
||||||
|
radar::drivers::H7992MinimalDriverSettings{
|
||||||
|
.name = sc.name,
|
||||||
|
.mode = sc.driver_mode,
|
||||||
|
.positions = sc.positions,
|
||||||
|
.default_position = sc.default_position,
|
||||||
|
.gpio_chip = sc.gpio_chip,
|
||||||
|
.pin_a = sc.pin_a,
|
||||||
|
.pin_b = sc.pin_b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the switch sequencer from the run_config given via `config:<path>`.
|
||||||
|
// Returns nullptr (switching disabled, single-channel passthrough) when no config
|
||||||
|
// was supplied or it declares no combinations.
|
||||||
|
std::unique_ptr<radar::kamil::SwitchSequencer> make_switch_sequencer(const Config& cfg) {
|
||||||
|
if (!cfg.run_config_path.has_value()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const radar::config::RunConfig rc = radar::config::load_run_config(*cfg.run_config_path);
|
||||||
|
if (rc.run_combos.empty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
radar::kamil::SwitchSequencer::Settings settings;
|
||||||
|
settings.combos = rc.run_combos;
|
||||||
|
settings.gap_ms = cfg.switch_gap_ms;
|
||||||
|
settings.settle_ms = cfg.switch_settle_ms;
|
||||||
|
return std::make_unique<radar::kamil::SwitchSequencer>(
|
||||||
|
std::move(settings),
|
||||||
|
make_switch_driver(rc.input_switch),
|
||||||
|
make_switch_driver(rc.output_switch));
|
||||||
|
}
|
||||||
|
|
||||||
int run(const Config& cfg) {
|
int run(const Config& cfg) {
|
||||||
Api api;
|
Api api;
|
||||||
DeviceHandle device(api);
|
DeviceHandle device(api);
|
||||||
@@ -2029,6 +2113,21 @@ int run(const Config& cfg) {
|
|||||||
tty_writer->emit_packet_start(tty_packet_start_marker);
|
tty_writer->emit_packet_start(tty_packet_start_marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Switch sequencer (optional): drives the RF switches in lock-step with sweep
|
||||||
|
// boundaries and tags each sweep with its combination. Disabled (nullptr) when
|
||||||
|
// no run_config was passed, so the standalone/calibration use stays unchanged.
|
||||||
|
std::unique_ptr<radar::kamil::SwitchSequencer> sequencer = make_switch_sequencer(cfg);
|
||||||
|
if (sequencer && sequencer->enabled()) {
|
||||||
|
sequencer->open();
|
||||||
|
std::cout << "Switch sequencer enabled: cycling switch combinations per sweep "
|
||||||
|
<< "(gap_ms=" << cfg.switch_gap_ms << ", settle_ms=" << cfg.switch_settle_ms << ")\n";
|
||||||
|
} else {
|
||||||
|
sequencer.reset(); // normalize to nullptr so call sites can test the pointer
|
||||||
|
}
|
||||||
|
// Estimated readout backlog (ms) at the current loop iteration; how far behind
|
||||||
|
// real time we are, used to decide whether an in-gap switch lands in time.
|
||||||
|
double current_backlog_ms = 0.0;
|
||||||
|
|
||||||
std::unique_ptr<CaptureFileWriter> writer;
|
std::unique_ptr<CaptureFileWriter> writer;
|
||||||
if (!fast_tty_avg_stream_mode) {
|
if (!fast_tty_avg_stream_mode) {
|
||||||
writer = std::make_unique<CaptureFileWriter>(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path);
|
writer = std::make_unique<CaptureFileWriter>(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path);
|
||||||
@@ -2317,6 +2416,15 @@ int run(const Config& cfg) {
|
|||||||
|
|
||||||
auto append_tty_packet_start = [&]() {
|
auto append_tty_packet_start = [&]() {
|
||||||
append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF);
|
append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF);
|
||||||
|
// Right after the boundary, tag the upcoming sweep with its switch combo so
|
||||||
|
// the consumer can group sweeps and drop the dirty ones.
|
||||||
|
if (sequencer) {
|
||||||
|
const radar::ipc::ComboKey combo = sequencer->current_combo();
|
||||||
|
append_tty_frame(kTtyMarkerComboTag,
|
||||||
|
static_cast<uint16_t>(combo.input_pos),
|
||||||
|
static_cast<uint16_t>(combo.output_pos),
|
||||||
|
static_cast<uint16_t>(sequencer->current_dirty() ? 1U : 0U));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
auto append_tty_group_step = [&]() {
|
auto append_tty_group_step = [&]() {
|
||||||
@@ -2714,6 +2822,12 @@ int run(const Config& cfg) {
|
|||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A real sweep just completed: step the switch sequence into the gap. A
|
||||||
|
// user-stop teardown is not a sweep boundary, so skip it.
|
||||||
|
if (sequencer && (frames != 0U) && (reason != PacketCloseReason::UserStop)) {
|
||||||
|
sequencer->on_sweep_end(current_backlog_ms);
|
||||||
|
}
|
||||||
|
|
||||||
packet_active = false;
|
packet_active = false;
|
||||||
packet_avg_steps = 0;
|
packet_avg_steps = 0;
|
||||||
fast_packet_frames = 0;
|
fast_packet_frames = 0;
|
||||||
@@ -2768,6 +2882,11 @@ int run(const Config& cfg) {
|
|||||||
recv_request_words = std::min<uint32_t>(ready_words, read_capacity_words);
|
recv_request_words = std::min<uint32_t>(ready_words, read_capacity_words);
|
||||||
recv_timeout_ms = 0;
|
recv_timeout_ms = 0;
|
||||||
}
|
}
|
||||||
|
// Backlog already queued in the driver = how far behind real time we are.
|
||||||
|
// Drives the switch sequencer's in-gap clean/dirty decision.
|
||||||
|
if ((ready_err == X502_ERR_OK) && (combined_input_rate_hz > 0.0)) {
|
||||||
|
current_backlog_ms = 1000.0 * static_cast<double>(ready_words) / combined_input_rate_hz;
|
||||||
|
}
|
||||||
|
|
||||||
const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms);
|
const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms);
|
||||||
if (recvd < 0) {
|
if (recvd < 0) {
|
||||||
@@ -3170,6 +3289,11 @@ int run(const Config& cfg) {
|
|||||||
|
|
||||||
expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
|
expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
|
||||||
device.streams_started = false;
|
device.streams_started = false;
|
||||||
|
|
||||||
|
// Park the switches in their safe default and release the GPIO lines.
|
||||||
|
if (sequencer) {
|
||||||
|
sequencer->close();
|
||||||
|
}
|
||||||
if (cfg.do1_toggle_per_frame) {
|
if (cfg.do1_toggle_per_frame) {
|
||||||
const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
|
const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
|
||||||
expect_ok(api,
|
expect_ok(api,
|
||||||
|
|||||||
+9
-5
@@ -45,8 +45,10 @@ struct ProcessingLiveConfig {
|
|||||||
float gpr_min_depth_m = 2.0F;
|
float gpr_min_depth_m = 2.0F;
|
||||||
float gpr_max_depth_m = 14.0F;
|
float gpr_max_depth_m = 14.0F;
|
||||||
float gpr_range_comp_power = 0.1F;
|
float gpr_range_comp_power = 0.1F;
|
||||||
float gpr_angle_comp_power = 0.0F;
|
|
||||||
float gpr_comp_power = 0.2F;
|
float gpr_comp_power = 0.2F;
|
||||||
|
// BP object-detection stop level, as a fraction of the global peak (Python
|
||||||
|
// Horns_motion_3libre.py BP_OBJECT_MIN_FRAC); peaks below it are not objects.
|
||||||
|
float gpr_object_min_frac = 0.7F;
|
||||||
std::string gpr_score_mode = "combined";
|
std::string gpr_score_mode = "combined";
|
||||||
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
||||||
// correction) or "int_focus" (focusing residual only). Mirrors the Python
|
// correction) or "int_focus" (focusing residual only). Mirrors the Python
|
||||||
@@ -73,13 +75,15 @@ 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
|
// Coherent BP object visibility (window + the draw limits below) is applied in
|
||||||
// `legacy_gpr_min_visible_pair_count`, everything else uses
|
// the processor itself, matching Horns_motion_3libre.py — there is NO score
|
||||||
// `gpr_min_visible_score`). Draw limits apply only to non-legacy modes.
|
// threshold for it. Only legacy GPR still thresholds, on a pair count.
|
||||||
float gpr_min_visible_score = 0.0F;
|
|
||||||
float legacy_gpr_min_visible_pair_count = 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_max_detected_objects_to_draw = 0;
|
||||||
std::uint32_t gpr_draw_top_m_objects = 0;
|
std::uint32_t gpr_draw_top_m_objects = 0;
|
||||||
|
// Cross-frame approach filter: an object is shown only once it persists as a
|
||||||
|
// motion-consistent track over this many consecutive frames (<= 1 disables it).
|
||||||
|
std::uint32_t gpr_object_approach_min_frames = 3;
|
||||||
// Visible X/Z window (metres). The locator clips broadcast objects to this
|
// Visible X/Z window (metres). The locator clips broadcast objects to this
|
||||||
// window so the socket emits only what the desktop plot actually shows.
|
// window so the socket emits only what the desktop plot actually shows.
|
||||||
float gpr_visible_x_min_m = -2.0F;
|
float gpr_visible_x_min_m = -2.0F;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ 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;
|
||||||
std::uint64_t error_count = 0;
|
std::uint64_t error_count = 0;
|
||||||
std::uint64_t consecutive_errors = 0;
|
std::uint64_t consecutive_errors = 0;
|
||||||
// Fix #55: track the socket-fed speed used for the last reprocess so a change
|
// track the socket-fed speed used for the last reprocess so a change
|
||||||
// arriving without a live-config revision bump still triggers a reprocess of
|
// arriving without a live-config revision bump still triggers a reprocess of
|
||||||
// the current result (gated below by reprocess_current_result).
|
// the current result (gated below by reprocess_current_result).
|
||||||
std::optional<double> last_reprocessed_socket_speed = std::nullopt;
|
std::optional<double> last_reprocessed_socket_speed = std::nullopt;
|
||||||
@@ -124,7 +124,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
publish_locator(replay_result, live_config);
|
publish_locator(replay_result, live_config);
|
||||||
}
|
}
|
||||||
last_replayed_revision = live_revision;
|
last_replayed_revision = live_revision;
|
||||||
// Fix #55: record the speed we just reprocessed with so an
|
//record the speed we just reprocessed with so an
|
||||||
// unchanged socket value does not retrigger every iteration.
|
// unchanged socket value does not retrigger every iteration.
|
||||||
last_reprocessed_socket_speed = current_socket_speed;
|
last_reprocessed_socket_speed = current_socket_speed;
|
||||||
}
|
}
|
||||||
@@ -232,29 +232,23 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config
|
|||||||
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
|
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
|
||||||
|
|
||||||
radar::locator::FilterParams filter{};
|
radar::locator::FilterParams filter{};
|
||||||
// Clip broadcast objects to the same visible X/Z window the desktop plot uses,
|
if (requested_mode == "legacy_gpr") {
|
||||||
// so the socket emits only the objects the operator actually sees.
|
// Legacy GPR emits its objects unfiltered, so the socket applies the legacy
|
||||||
|
// rule here: clip to the visible window and threshold on the pair count. The
|
||||||
|
// GUI disables "draw top N" for legacy, so we skip it on the wire to match.
|
||||||
filter.visible_bounds = radar::locator::VisibleBounds{
|
filter.visible_bounds = radar::locator::VisibleBounds{
|
||||||
.x_min = live_config.gpr_visible_x_min_m,
|
.x_min = live_config.gpr_visible_x_min_m,
|
||||||
.x_max = live_config.gpr_visible_x_max_m,
|
.x_max = live_config.gpr_visible_x_max_m,
|
||||||
.z_min = live_config.gpr_visible_z_min_m,
|
.z_min = live_config.gpr_visible_z_min_m,
|
||||||
.z_max = live_config.gpr_visible_z_max_m,
|
.z_max = live_config.gpr_visible_z_max_m,
|
||||||
};
|
};
|
||||||
if (requested_mode == "legacy_gpr") {
|
|
||||||
filter.min_score = live_config.legacy_gpr_min_visible_pair_count;
|
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();
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Coherent BP already emits the FINAL visible object set from the processor
|
||||||
|
// (window + N/M, no score threshold — matching Horns_motion_3libre.py), so the
|
||||||
|
// socket forwards it verbatim. The default FilterParams passes everything through
|
||||||
|
// (it only drops non-finite rows), keeping the filtering logic in one place.
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,11 +255,11 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
|||||||
}
|
}
|
||||||
config.gpr_range_comp_power = static_cast<float>(found->get<double>());
|
config.gpr_range_comp_power = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
if (const auto found = root.find("gpr_angle_comp_power"); found != root.end()) {
|
if (const auto found = root.find("gpr_object_min_frac"); found != root.end()) {
|
||||||
if (!found->is_number()) {
|
if (!found->is_number()) {
|
||||||
throw std::runtime_error("processing.gpr_angle_comp_power must be number");
|
throw std::runtime_error("processing.gpr_object_min_frac must be number");
|
||||||
}
|
}
|
||||||
config.gpr_angle_comp_power = static_cast<float>(found->get<double>());
|
config.gpr_object_min_frac = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
if (const auto found = root.find("gpr_comp_power"); found != root.end()) {
|
if (const auto found = root.find("gpr_comp_power"); found != root.end()) {
|
||||||
if (!found->is_number()) {
|
if (!found->is_number()) {
|
||||||
@@ -355,12 +355,6 @@ 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>());
|
|
||||||
}
|
|
||||||
for (const auto& [key, target] : {
|
for (const auto& [key, target] : {
|
||||||
std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m},
|
std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m},
|
||||||
std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m},
|
std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m},
|
||||||
@@ -388,6 +382,10 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
|||||||
config.gpr_draw_top_m_objects =
|
config.gpr_draw_top_m_objects =
|
||||||
parse_u32_number(*found, "processing.gpr_draw_top_m_objects");
|
parse_u32_number(*found, "processing.gpr_draw_top_m_objects");
|
||||||
}
|
}
|
||||||
|
if (const auto found = root.find("gpr_object_approach_min_frames"); found != root.end()) {
|
||||||
|
config.gpr_object_approach_min_frames =
|
||||||
|
parse_u32_number(*found, "processing.gpr_object_approach_min_frames");
|
||||||
|
}
|
||||||
if (const auto found = root.find("ignore_socket_speed"); found != root.end()) {
|
if (const auto found = root.find("ignore_socket_speed"); found != root.end()) {
|
||||||
if (!found->is_boolean()) {
|
if (!found->is_boolean()) {
|
||||||
throw std::runtime_error("processing.ignore_socket_speed must be bool");
|
throw std::runtime_error("processing.ignore_socket_speed must be bool");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "object_approach_filter.hpp"
|
||||||
#include "processor_interface.hpp"
|
#include "processor_interface.hpp"
|
||||||
|
|
||||||
namespace radar::processing {
|
namespace radar::processing {
|
||||||
@@ -13,6 +14,11 @@ class GprProcessor final : public ProcessorInterface {
|
|||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultCollection override;
|
) -> ipc::ResultCollection override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Cross-frame "approach" track filter. Persists across collections because the
|
||||||
|
// owning processor instance is long-lived (one per data_processor run).
|
||||||
|
ObjectApproachFilter approach_filter_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
class LegacyGprProcessor final : public ProcessorInterface {
|
class LegacyGprProcessor final : public ProcessorInterface {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <limits>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace radar::processing {
|
||||||
|
|
||||||
|
// Temporal "approach" filter for coherent-BP detections — a port of the objects-only
|
||||||
|
// filter in Horns_motion_3libre's demonstrate notebook.
|
||||||
|
//
|
||||||
|
// It keeps only objects that persist as a *motion-consistent track* across at least
|
||||||
|
// `min_frames` consecutive frames: as the radar moves, a real target reappears at a
|
||||||
|
// predictable, shifting (x, z), whereas a one-frame noise spike forms no track and is
|
||||||
|
// dropped. The expected per-frame change in range is `speed * dt * cos(look_angle)`,
|
||||||
|
// where `dt` is the real interval between consecutive frames (so dropped frames and a
|
||||||
|
// varying frame period are handled naturally).
|
||||||
|
//
|
||||||
|
// CAUSAL: unlike the offline notebook (which can look forward over the whole sequence),
|
||||||
|
// this confirms a track by looking *backward* — an object is kept once it ends a track
|
||||||
|
// of `min_frames` frames seen so far. A target therefore first appears after it has
|
||||||
|
// persisted `min_frames` frames; the early frames of its track are not shown
|
||||||
|
// retroactively.
|
||||||
|
//
|
||||||
|
// Stateless across pipeline restarts is approximated by breaking tracks across a large
|
||||||
|
// inter-frame gap (`kMaxFrameGapSeconds`), so a stale history from a previous run cannot
|
||||||
|
// spuriously confirm objects.
|
||||||
|
//
|
||||||
|
// IDEMPOTENT under reprocessing: the data_processor re-runs the last collection (or
|
||||||
|
// replays the whole window) whenever live settings or the socket speed change, with no
|
||||||
|
// new sweep. The history is therefore keyed by `frame_id` (the strictly increasing
|
||||||
|
// collection id): the same id replaces its entry (reprocess of the current frame), a
|
||||||
|
// smaller id rebuilds from scratch (a replay restart), so repeated reprocessing never
|
||||||
|
// duplicates frames or falsely confirms a track.
|
||||||
|
class ObjectApproachFilter {
|
||||||
|
public:
|
||||||
|
struct Point {
|
||||||
|
double x_m;
|
||||||
|
double z_m;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record `objects` as frame `frame_id` and return, per object, whether it is
|
||||||
|
// confirmed (ends a >= `min_frames` motion-consistent track). `frame_id` is the
|
||||||
|
// collection id (identity/order, survives reprocessing); `frame_time_seconds` is the
|
||||||
|
// frame's wall-clock timestamp (drives the inter-frame interval); `speed_m_s`/
|
||||||
|
// `look_angle_deg` are the live motion estimate. `min_frames <= 1` disables filtering.
|
||||||
|
[[nodiscard]] auto confirm(
|
||||||
|
const std::vector<Point>& objects,
|
||||||
|
std::uint64_t frame_id,
|
||||||
|
double frame_time_seconds,
|
||||||
|
double speed_m_s,
|
||||||
|
double look_angle_deg,
|
||||||
|
std::size_t min_frames
|
||||||
|
) -> std::vector<bool> {
|
||||||
|
record_frame(Frame{frame_id, frame_time_seconds, objects});
|
||||||
|
const std::size_t history_depth = std::max<std::size_t>(min_frames, 1U);
|
||||||
|
while (history_.size() > history_depth) {
|
||||||
|
history_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<bool> confirmed(objects.size(), min_frames <= 1U);
|
||||||
|
if (min_frames <= 1U || history_.size() < min_frames) {
|
||||||
|
return confirmed; // disabled, or not enough history yet to confirm anything
|
||||||
|
}
|
||||||
|
|
||||||
|
const double range_step_per_second = std::abs(speed_m_s) * std::cos(to_radians(look_angle_deg));
|
||||||
|
for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) {
|
||||||
|
confirmed[object_index] = has_backward_track(objects[object_index], min_frames, range_step_per_second);
|
||||||
|
}
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() { history_.clear(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Frame {
|
||||||
|
std::uint64_t id;
|
||||||
|
double time_seconds;
|
||||||
|
std::vector<Point> objects;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append a genuinely new frame, replace the current one on reprocessing (same id),
|
||||||
|
// or rebuild from scratch when the id steps backward (a replay restart). This keeps
|
||||||
|
// the history one entry per distinct collection no matter how often settings change.
|
||||||
|
void record_frame(Frame frame) {
|
||||||
|
if (history_.empty() || frame.id > history_.back().id) {
|
||||||
|
history_.push_back(std::move(frame));
|
||||||
|
} else if (frame.id == history_.back().id) {
|
||||||
|
history_.back() = std::move(frame);
|
||||||
|
} else {
|
||||||
|
history_.clear();
|
||||||
|
history_.push_back(std::move(frame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed matching tolerances (Horns_motion notebook 0.2 block). Range decreases as the
|
||||||
|
// radar approaches the target, hence the negative Z sign.
|
||||||
|
static constexpr double kXToleranceM = 0.45;
|
||||||
|
static constexpr double kRangeToleranceFraction = 0.85;
|
||||||
|
static constexpr double kRangeToleranceFloorM = 0.12;
|
||||||
|
static constexpr double kRangeSign = -1.0;
|
||||||
|
static constexpr double kMaxFrameGapSeconds = 2.0;
|
||||||
|
|
||||||
|
[[nodiscard]] static auto to_radians(double degrees) -> double {
|
||||||
|
return degrees * (M_PI / 180.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk back from the current object through the history, matching a motion-consistent
|
||||||
|
// predecessor in each earlier frame. Confirmed iff a full chain of `min_frames` frames
|
||||||
|
// (the current one plus `min_frames - 1` predecessors) is found.
|
||||||
|
[[nodiscard]] auto has_backward_track(
|
||||||
|
const Point& object,
|
||||||
|
std::size_t min_frames,
|
||||||
|
double range_step_per_second
|
||||||
|
) const -> bool {
|
||||||
|
const std::size_t newest = history_.size() - 1U;
|
||||||
|
Point current = object;
|
||||||
|
for (std::size_t step = 1U; step < min_frames; ++step) {
|
||||||
|
const std::size_t earlier_index = newest - step;
|
||||||
|
const Frame& earlier = history_[earlier_index];
|
||||||
|
const double dt = history_[earlier_index + 1U].time_seconds - earlier.time_seconds;
|
||||||
|
if (!(dt > 0.0) || dt > kMaxFrameGapSeconds) {
|
||||||
|
return false; // non-monotonic time, or a gap that breaks the track
|
||||||
|
}
|
||||||
|
const double expected_range_shift = range_step_per_second * dt;
|
||||||
|
const Point* predecessor = match_predecessor(current, earlier.objects, expected_range_shift);
|
||||||
|
if (predecessor == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
current = *predecessor;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The best earlier-frame object consistent with `object` having moved by one frame:
|
||||||
|
// its range was larger by `expected_range_shift` (radar since approached), within the
|
||||||
|
// cross-range and range tolerances. Returns nullptr when nothing matches.
|
||||||
|
[[nodiscard]] static auto match_predecessor(
|
||||||
|
const Point& object,
|
||||||
|
const std::vector<Point>& candidates,
|
||||||
|
double expected_range_shift
|
||||||
|
) -> const Point* {
|
||||||
|
const double target_z = object.z_m - (kRangeSign * expected_range_shift);
|
||||||
|
const double range_tolerance =
|
||||||
|
std::max(kRangeToleranceFloorM, kRangeToleranceFraction * expected_range_shift);
|
||||||
|
|
||||||
|
const Point* best = nullptr;
|
||||||
|
double best_cost = std::numeric_limits<double>::infinity();
|
||||||
|
for (const Point& candidate : candidates) {
|
||||||
|
const double dx = std::abs(candidate.x_m - object.x_m);
|
||||||
|
const double dz = std::abs(candidate.z_m - target_z);
|
||||||
|
if (dx > kXToleranceM || dz > range_tolerance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double cost = (dx / kXToleranceM) * (dx / kXToleranceM)
|
||||||
|
+ (dz / range_tolerance) * (dz / range_tolerance);
|
||||||
|
if (cost < best_cost) {
|
||||||
|
best = &candidate;
|
||||||
|
best_cost = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::deque<Frame> history_{}; // recent frames, newest at the back; capped to min_frames
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace radar::processing
|
||||||
+62
-14
@@ -21,7 +21,6 @@ constexpr double kSmoothSigma = 1.5;
|
|||||||
// same default 'reflect' (half-sample symmetric) extension — see reflect_index.
|
// same default 'reflect' (half-sample symmetric) extension — see reflect_index.
|
||||||
constexpr double kGaussianTruncate = 4.0;
|
constexpr double kGaussianTruncate = 4.0;
|
||||||
constexpr std::size_t kMaxObjects = 10U;
|
constexpr std::size_t kMaxObjects = 10U;
|
||||||
constexpr double kObjectMinFrac = 0.7;
|
|
||||||
constexpr double kRegionThresholdFrac = 0.75;
|
constexpr double kRegionThresholdFrac = 0.75;
|
||||||
constexpr double kSuppressThresholdFrac = 0.20;
|
constexpr double kSuppressThresholdFrac = 0.20;
|
||||||
constexpr double kSuppressRadiusXM = 0.80;
|
constexpr double kSuppressRadiusXM = 0.80;
|
||||||
@@ -1405,7 +1404,8 @@ void apply_depth_gate(
|
|||||||
|
|
||||||
[[nodiscard]] auto find_bp_objects(
|
[[nodiscard]] auto find_bp_objects(
|
||||||
const std::vector<double>& bp_image,
|
const std::vector<double>& bp_image,
|
||||||
const GridDefinition& grid
|
const GridDefinition& grid,
|
||||||
|
double min_frac
|
||||||
) -> std::vector<ObjectRecord> {
|
) -> std::vector<ObjectRecord> {
|
||||||
std::vector<ObjectRecord> objects{};
|
std::vector<ObjectRecord> objects{};
|
||||||
if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
||||||
@@ -1414,7 +1414,7 @@ void apply_depth_gate(
|
|||||||
|
|
||||||
std::vector<double> work = bp_image;
|
std::vector<double> work = bp_image;
|
||||||
const double global_peak = max_value(work);
|
const double global_peak = max_value(work);
|
||||||
const double stop_level = kObjectMinFrac * global_peak;
|
const double stop_level = min_frac * global_peak;
|
||||||
if (!(global_peak > 0.0)) {
|
if (!(global_peak > 0.0)) {
|
||||||
return objects;
|
return objects;
|
||||||
}
|
}
|
||||||
@@ -1740,7 +1740,15 @@ void add_bp_score_metrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] auto output_objects_sorted(
|
// Select the FINAL visible objects exactly as Horns_motion_3libre.py does, so the
|
||||||
|
// processor is the single source of truth: the GUI plot and the locator socket both
|
||||||
|
// consume this set verbatim (no second, duplicated filter). Steps, in order:
|
||||||
|
// 1. drop sidelobe candidates (when enabled), then sort by score (peak tie-break);
|
||||||
|
// 2. clip to the visible X/Z window (display window doubles as an object gate);
|
||||||
|
// 3. apply the N/M draw rule (BP_MAX_DETECTED_OBJECTS_TO_DRAW / BP_DRAW_TOP_M_OBJECTS):
|
||||||
|
// if more than N survive, show none; otherwise keep the top M.
|
||||||
|
// There is deliberately NO score threshold (the Python reference has none).
|
||||||
|
[[nodiscard]] auto select_visible_objects(
|
||||||
const std::vector<ObjectRecord>& objects,
|
const std::vector<ObjectRecord>& objects,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> std::vector<const ObjectRecord*> {
|
) -> std::vector<const ObjectRecord*> {
|
||||||
@@ -1750,6 +1758,12 @@ void add_bp_score_metrics(
|
|||||||
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
|
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (object.x_m < live_config.gpr_visible_x_min_m
|
||||||
|
|| object.x_m > live_config.gpr_visible_x_max_m
|
||||||
|
|| object.z_m < live_config.gpr_visible_z_min_m
|
||||||
|
|| object.z_m > live_config.gpr_visible_z_max_m) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
visible.push_back(&object);
|
visible.push_back(&object);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,6 +1773,17 @@ void add_bp_score_metrics(
|
|||||||
}
|
}
|
||||||
return left->selected_score > right->selected_score;
|
return left->selected_score > right->selected_score;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// N/M draw rule (0 on either disables limiting, mirroring the GUI/locator default).
|
||||||
|
const auto max_detected = live_config.gpr_max_detected_objects_to_draw;
|
||||||
|
const auto draw_top = live_config.gpr_draw_top_m_objects;
|
||||||
|
if (max_detected > 0U && draw_top > 0U) {
|
||||||
|
if (visible.size() > max_detected) {
|
||||||
|
visible.clear();
|
||||||
|
} else if (visible.size() > draw_top) {
|
||||||
|
visible.resize(draw_top);
|
||||||
|
}
|
||||||
|
}
|
||||||
return visible;
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1815,7 +1840,8 @@ void add_bp_score_metrics(
|
|||||||
const config::RunConfig& run_config,
|
const config::RunConfig& run_config,
|
||||||
const ipc::PreprocessedCollection& collection,
|
const ipc::PreprocessedCollection& collection,
|
||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config,
|
||||||
|
ObjectApproachFilter& approach_filter
|
||||||
) -> ipc::ResultCollection {
|
) -> ipc::ResultCollection {
|
||||||
ipc::ResultCollection results{};
|
ipc::ResultCollection results{};
|
||||||
results.collection_id = collection.collection_id;
|
results.collection_id = collection.collection_id;
|
||||||
@@ -1833,8 +1859,10 @@ void add_bp_score_metrics(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
const double velocity_mps =
|
// Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python
|
||||||
kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast<double>(run_config.gpr.relative_permittivity)));
|
// reference Horns_motion_3libre.py (its 0.3 block hardcodes eps_r = 1.0). The
|
||||||
|
// configurable relative_permittivity stays a legacy-GPR-only knob.
|
||||||
|
const double velocity_mps = kSpeedOfLightMetersPerSec;
|
||||||
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
|
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
|
||||||
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
|
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
|
||||||
const double min_depth_m = static_cast<double>(live_config.gpr_min_depth_m);
|
const double min_depth_m = static_cast<double>(live_config.gpr_min_depth_m);
|
||||||
@@ -1920,7 +1948,7 @@ void add_bp_score_metrics(
|
|||||||
min_depth_m,
|
min_depth_m,
|
||||||
max_depth_m,
|
max_depth_m,
|
||||||
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
|
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
|
||||||
std::max(0.0, static_cast<double>(live_config.gpr_angle_comp_power))
|
0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0)
|
||||||
);
|
);
|
||||||
if (bp.image.empty()) {
|
if (bp.image.empty()) {
|
||||||
return results;
|
return results;
|
||||||
@@ -1930,7 +1958,7 @@ void add_bp_score_metrics(
|
|||||||
const auto incoherent_display_map =
|
const auto incoherent_display_map =
|
||||||
normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma);
|
normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma);
|
||||||
|
|
||||||
auto objects = find_bp_objects(display_map, grid);
|
auto objects = find_bp_objects(display_map, grid, static_cast<double>(live_config.gpr_object_min_frac));
|
||||||
add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m);
|
add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m);
|
||||||
add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
|
add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
|
||||||
mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m);
|
mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m);
|
||||||
@@ -1951,14 +1979,34 @@ void add_bp_score_metrics(
|
|||||||
|
|
||||||
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map));
|
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map));
|
||||||
|
|
||||||
|
// Final visible set (window + N/M), then the cross-frame approach filter: keep only
|
||||||
|
// objects confirmed as a >= min_frames motion-consistent track (Horns_motion notebook).
|
||||||
|
const auto visible = select_visible_objects(objects, live_config);
|
||||||
|
std::vector<ObjectApproachFilter::Point> visible_points{};
|
||||||
|
visible_points.reserve(visible.size());
|
||||||
|
for (const auto* object : visible) {
|
||||||
|
visible_points.push_back({object->x_m, object->z_m});
|
||||||
|
}
|
||||||
|
const auto confirmed = approach_filter.confirm(
|
||||||
|
visible_points,
|
||||||
|
collection.collection_id,
|
||||||
|
static_cast<double>(collection.monotonic_ns) * 1e-9,
|
||||||
|
static_cast<double>(live_config.gpr_speed_m_s),
|
||||||
|
static_cast<double>(live_config.gpr_look_angle_deg),
|
||||||
|
static_cast<std::size_t>(live_config.gpr_object_approach_min_frames)
|
||||||
|
);
|
||||||
|
|
||||||
std::vector<std::vector<float>> point_rows{};
|
std::vector<std::vector<float>> point_rows{};
|
||||||
point_rows.reserve(objects.size());
|
point_rows.reserve(visible.size());
|
||||||
for (const auto* object : output_objects_sorted(objects, live_config)) {
|
for (std::size_t index = 0U; index < visible.size(); ++index) {
|
||||||
|
if (!confirmed[index]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
point_rows.push_back(
|
point_rows.push_back(
|
||||||
{
|
{
|
||||||
static_cast<float>(object->x_m),
|
static_cast<float>(visible[index]->x_m),
|
||||||
static_cast<float>(object->z_m),
|
static_cast<float>(visible[index]->z_m),
|
||||||
static_cast<float>(object->selected_score),
|
static_cast<float>(visible[index]->selected_score),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ auto GprProcessor::process_collection(
|
|||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultCollection {
|
) -> ipc::ResultCollection {
|
||||||
return process_backprojection_gpr(run_config, collection, previous_collections, live_config);
|
return process_backprojection_gpr(run_config, collection, previous_collections, live_config, approach_filter_);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto LegacyGprProcessor::name() const -> std::string {
|
auto LegacyGprProcessor::name() const -> std::string {
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ SweepOrchestrator::SweepOrchestrator(
|
|||||||
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
||||||
// Fail fast on a config error: a slot that is too small for the worst-case payload can
|
// Fail fast on a config error: a slot that is too small for the worst-case payload can
|
||||||
// never carry a full collection, so report it clearly at startup instead of dropping
|
// never carry a full collection, so report it clearly at startup instead of dropping
|
||||||
// every collection at runtime (fix #27).
|
// every collection at runtime.
|
||||||
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
|
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
|
||||||
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
|
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
@@ -219,7 +219,7 @@ void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
|
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
|
||||||
// Wait for the devices to become available before starting (fix #8/#9): an absent device
|
// Wait for the devices to become available before starting: an absent device
|
||||||
// makes the orchestrator wait, not exit.
|
// makes the orchestrator wait, not exit.
|
||||||
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
||||||
return; // stop requested before any device became available
|
return; // stop requested before any device became available
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ _WEB_LIVE_SCHEMA = [
|
|||||||
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
|
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
|
||||||
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
|
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
|
||||||
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
|
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
|
||||||
("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")),
|
|
||||||
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
|
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
|
||||||
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
|
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
|
||||||
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
|
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
|
||||||
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
|
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
|
||||||
("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")),
|
("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")),
|
||||||
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
|
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
|
||||||
("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")),
|
("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")),
|
||||||
|
("gpr_object_approach_min_frames", "Detection", ("gpr",), _attr("_gpr_object_approach_min_frames")),
|
||||||
("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")),
|
("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")),
|
||||||
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
||||||
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
||||||
@@ -110,7 +110,9 @@ _WEB_DISPLAY_SCHEMA = [
|
|||||||
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
|
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
|
||||||
# "applies on Start" and editing them just updates the widget for the next start.
|
# "applies on Start" and editing them just updates the widget for the next start.
|
||||||
_WEB_STABLE_SCHEMA = [
|
_WEB_STABLE_SCHEMA = [
|
||||||
("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")),
|
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative
|
||||||
|
# permittivity is a legacy-GPR-only knob; BP ignores it.
|
||||||
|
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
|
||||||
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
|
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
|
||||||
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
|
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
|
||||||
]
|
]
|
||||||
@@ -479,14 +481,13 @@ class AppWindowLiveProcessingMixin:
|
|||||||
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
||||||
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
|
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
|
||||||
f"range_comp={self._gpr_range_comp_power.value():g}, "
|
f"range_comp={self._gpr_range_comp_power.value():g}, "
|
||||||
f"angle_comp={self._gpr_angle_comp_power.value():g}, "
|
f"object_min_frac={self._gpr_object_min_frac.value():g}, "
|
||||||
f"score_mode={self._gpr_score_mode.currentText()}, "
|
f"score_mode={self._gpr_score_mode.currentText()}, "
|
||||||
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
||||||
f"mean_count={self._gpr_background_mean_count.value()}, "
|
f"mean_count={self._gpr_background_mean_count.value()}, "
|
||||||
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
|
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
|
||||||
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
|
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
|
||||||
f"render_mode={self._gpr_render_mode.currentText()}, "
|
f"render_mode={self._gpr_render_mode.currentText()}, "
|
||||||
f"min_score={self._gpr_min_visible_score.value():g}, "
|
|
||||||
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
|
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
|
||||||
f"draw_top={self._gpr_draw_top_m_objects.value()})"
|
f"draw_top={self._gpr_draw_top_m_objects.value()})"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_min_depth_m,
|
self._gpr_min_depth_m,
|
||||||
self._gpr_max_depth_m,
|
self._gpr_max_depth_m,
|
||||||
self._gpr_range_comp_power,
|
self._gpr_range_comp_power,
|
||||||
self._gpr_angle_comp_power,
|
self._gpr_object_min_frac,
|
||||||
self._gpr_score_mode,
|
self._gpr_score_mode,
|
||||||
self._gpr_motion_mode,
|
self._gpr_motion_mode,
|
||||||
self._gpr_look_angle_deg,
|
self._gpr_look_angle_deg,
|
||||||
@@ -301,6 +301,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_speed_m_s,
|
self._gpr_speed_m_s,
|
||||||
self._gpr_max_detected_objects_to_draw,
|
self._gpr_max_detected_objects_to_draw,
|
||||||
self._gpr_draw_top_m_objects,
|
self._gpr_draw_top_m_objects,
|
||||||
|
self._gpr_object_approach_min_frames,
|
||||||
self._gpr_start_freq_mhz,
|
self._gpr_start_freq_mhz,
|
||||||
self._gpr_stop_freq_mhz,
|
self._gpr_stop_freq_mhz,
|
||||||
self._gpr_background_subtract_enabled,
|
self._gpr_background_subtract_enabled,
|
||||||
@@ -308,7 +309,6 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_remove_sidelobe_objects_enabled,
|
self._gpr_remove_sidelobe_objects_enabled,
|
||||||
self._gpr_imaging_plane_y_m,
|
self._gpr_imaging_plane_y_m,
|
||||||
self._gpr_render_mode,
|
self._gpr_render_mode,
|
||||||
self._gpr_min_visible_score,
|
|
||||||
self._gpr_visible_x_min_m,
|
self._gpr_visible_x_min_m,
|
||||||
self._gpr_visible_x_max_m,
|
self._gpr_visible_x_max_m,
|
||||||
self._gpr_visible_z_min_m,
|
self._gpr_visible_z_min_m,
|
||||||
@@ -459,7 +459,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
|
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
|
||||||
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
|
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
|
||||||
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
|
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
|
||||||
self._gpr_angle_comp_power.setValue(float(gui_state.processing.gpr.angle_comp_power))
|
self._gpr_object_min_frac.setValue(float(gui_state.processing.gpr.object_min_frac))
|
||||||
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
|
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
|
||||||
self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode)
|
self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode)
|
||||||
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
||||||
@@ -472,6 +472,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
int(gui_state.processing.gpr.max_detected_objects_to_draw)
|
int(gui_state.processing.gpr.max_detected_objects_to_draw)
|
||||||
)
|
)
|
||||||
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
|
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
|
||||||
|
self._gpr_object_approach_min_frames.setValue(int(gui_state.processing.gpr.object_approach_min_frames))
|
||||||
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
|
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
|
||||||
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
||||||
self._gpr_background_subtract_enabled.setChecked(
|
self._gpr_background_subtract_enabled.setChecked(
|
||||||
@@ -483,7 +484,6 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
)
|
)
|
||||||
self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m))
|
self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m))
|
||||||
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
|
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
|
||||||
self._gpr_min_visible_score.setValue(float(gui_state.processing.gpr.min_visible_score))
|
|
||||||
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
|
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
|
||||||
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
|
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
|
||||||
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
|
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
min_depth_m=2.0,
|
min_depth_m=2.0,
|
||||||
max_depth_m=14.0,
|
max_depth_m=14.0,
|
||||||
range_comp_power=0.1,
|
range_comp_power=0.1,
|
||||||
angle_comp_power=0.0,
|
object_min_frac=0.7,
|
||||||
score_mode="combined",
|
score_mode="combined",
|
||||||
motion_mode="int_minus",
|
motion_mode="int_minus",
|
||||||
look_angle_deg=0.0,
|
look_angle_deg=0.0,
|
||||||
@@ -250,6 +250,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
ignore_socket_speed_enabled=False,
|
ignore_socket_speed_enabled=False,
|
||||||
max_detected_objects_to_draw=5,
|
max_detected_objects_to_draw=5,
|
||||||
draw_top_m_objects=2,
|
draw_top_m_objects=2,
|
||||||
|
object_approach_min_frames=3,
|
||||||
start_freq_mhz=3000.0,
|
start_freq_mhz=3000.0,
|
||||||
stop_freq_mhz=6000.0,
|
stop_freq_mhz=6000.0,
|
||||||
background_subtract_enabled=True,
|
background_subtract_enabled=True,
|
||||||
@@ -257,7 +258,6 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
remove_sidelobe_objects_enabled=True,
|
remove_sidelobe_objects_enabled=True,
|
||||||
imaging_plane_y_m=0.0,
|
imaging_plane_y_m=0.0,
|
||||||
render_mode="heatmap",
|
render_mode="heatmap",
|
||||||
min_visible_score=0.0,
|
|
||||||
visible_x_min_m=default_gpr_x_min_m,
|
visible_x_min_m=default_gpr_x_min_m,
|
||||||
visible_x_max_m=default_gpr_x_max_m,
|
visible_x_max_m=default_gpr_x_max_m,
|
||||||
visible_z_min_m=0.0,
|
visible_z_min_m=0.0,
|
||||||
@@ -369,7 +369,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
min_depth_m=float(self._gpr_min_depth_m.value()),
|
min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||||
max_depth_m=float(self._gpr_max_depth_m.value()),
|
max_depth_m=float(self._gpr_max_depth_m.value()),
|
||||||
range_comp_power=float(self._gpr_range_comp_power.value()),
|
range_comp_power=float(self._gpr_range_comp_power.value()),
|
||||||
angle_comp_power=float(self._gpr_angle_comp_power.value()),
|
object_min_frac=float(self._gpr_object_min_frac.value()),
|
||||||
score_mode=self._gpr_score_mode.currentText(),
|
score_mode=self._gpr_score_mode.currentText(),
|
||||||
motion_mode=self._gpr_motion_mode.currentText(),
|
motion_mode=self._gpr_motion_mode.currentText(),
|
||||||
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
||||||
@@ -378,6 +378,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()),
|
ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()),
|
||||||
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
|
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
|
||||||
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
|
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
|
||||||
|
object_approach_min_frames=int(self._gpr_object_approach_min_frames.value()),
|
||||||
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||||
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
||||||
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||||
@@ -385,7 +386,6 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
||||||
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||||
render_mode=self._gpr_render_mode.currentText(),
|
render_mode=self._gpr_render_mode.currentText(),
|
||||||
min_visible_score=float(self._gpr_min_visible_score.value()),
|
|
||||||
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
|
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
|
||||||
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
|
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
|
||||||
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import pyqtgraph as pg
|
|||||||
|
|
||||||
from python_app.models.dataset_model import ResultCollection
|
from python_app.models.dataset_model import ResultCollection
|
||||||
from python_app.orchestration.gpr_locator import (
|
from python_app.orchestration.gpr_locator import (
|
||||||
apply_object_draw_limits as gpr_apply_object_draw_limits,
|
|
||||||
collection_payload_by_name as gpr_collection_payload_by_name,
|
collection_payload_by_name as gpr_collection_payload_by_name,
|
||||||
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
||||||
filter_object_rows as gpr_filter_object_rows,
|
filter_object_rows as gpr_filter_object_rows,
|
||||||
@@ -371,26 +370,6 @@ class AppWindowGprPlotMixin:
|
|||||||
return self._legacy_gpr_render_mode.currentText()
|
return self._legacy_gpr_render_mode.currentText()
|
||||||
return self._gpr_render_mode.currentText()
|
return self._gpr_render_mode.currentText()
|
||||||
|
|
||||||
def _gpr_locator_threshold(self) -> float:
|
|
||||||
"""Return object threshold using the active GPR mode's score semantics."""
|
|
||||||
if self._processing_mode.currentText() == "legacy_gpr":
|
|
||||||
return float(self._legacy_gpr_min_visible_pair_count.value())
|
|
||||||
return float(self._gpr_min_visible_score.value())
|
|
||||||
|
|
||||||
def _gpr_draw_limits(self) -> tuple[int, int] | None:
|
|
||||||
"""Return GPR object draw limits, or None for legacy GPR."""
|
|
||||||
if self._processing_mode.currentText() == "legacy_gpr":
|
|
||||||
return None
|
|
||||||
return (
|
|
||||||
int(self._gpr_max_detected_objects_to_draw.value()),
|
|
||||||
int(self._gpr_draw_top_m_objects.value()),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray:
|
|
||||||
"""Apply object count/top-M drawing rules to already-filtered rows."""
|
|
||||||
return gpr_apply_object_draw_limits(rows, limits)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||||
"""Return lower display bound, preserving surface markers only when surface is visible."""
|
"""Return lower display bound, preserving surface markers only when surface is visible."""
|
||||||
@@ -506,18 +485,24 @@ class AppWindowGprPlotMixin:
|
|||||||
return extract_gpr_object_rows(collection)
|
return extract_gpr_object_rows(collection)
|
||||||
|
|
||||||
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
||||||
"""Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits."""
|
"""Return the object rows to draw for the active GPR mode.
|
||||||
|
|
||||||
|
Coherent BP is already finalized by the processor (visible window + N/M draw
|
||||||
|
limits, no score threshold — exactly Horns_motion_3libre.py), so its rows are
|
||||||
|
drawn verbatim. Legacy GPR is still filtered here by its pair-count threshold
|
||||||
|
and the visible window.
|
||||||
|
"""
|
||||||
rows = self._gpr_object_rows(collection)
|
rows = self._gpr_object_rows(collection)
|
||||||
if rows.size == 0:
|
if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr":
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||||
return gpr_filter_object_rows(
|
return gpr_filter_object_rows(
|
||||||
rows,
|
rows,
|
||||||
min_score=self._gpr_locator_threshold(),
|
min_score=float(self._legacy_gpr_min_visible_pair_count.value()),
|
||||||
x_bounds=(x_min, x_max),
|
x_bounds=(x_min, x_max),
|
||||||
z_bounds=(z_min, z_max),
|
z_bounds=(z_min, z_max),
|
||||||
draw_limits=self._gpr_draw_limits(),
|
draw_limits=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
|
|
||||||
gpr_defaults = owner._defaults_config.gpr
|
gpr_defaults = owner._defaults_config.gpr
|
||||||
|
|
||||||
|
# Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below).
|
||||||
|
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so it is not
|
||||||
|
# offered there. Applies on the next pipeline start (a run_config field).
|
||||||
owner._gpr_relative_permittivity = QDoubleSpinBox()
|
owner._gpr_relative_permittivity = QDoubleSpinBox()
|
||||||
owner._gpr_relative_permittivity.setDecimals(4)
|
owner._gpr_relative_permittivity.setDecimals(4)
|
||||||
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
|
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
|
||||||
owner._gpr_relative_permittivity.setSingleStep(0.05)
|
owner._gpr_relative_permittivity.setSingleStep(0.05)
|
||||||
|
owner._gpr_relative_permittivity.setToolTip("Applied on the next pipeline start (Save Config and restart).")
|
||||||
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
|
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
|
||||||
|
|
||||||
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
|
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
|
||||||
@@ -205,14 +209,13 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_common_page = _build_processing_mode_page(
|
owner._gpr_common_page = _build_processing_mode_page(
|
||||||
group,
|
group,
|
||||||
[
|
[
|
||||||
("Relative permittivity", owner._gpr_relative_permittivity),
|
|
||||||
("Tx geometry", owner._gpr_tx_geometry_input),
|
("Tx geometry", owner._gpr_tx_geometry_input),
|
||||||
("Rx geometry", owner._gpr_rx_geometry_input),
|
("Rx geometry", owner._gpr_rx_geometry_input),
|
||||||
],
|
],
|
||||||
split_index=1,
|
split_index=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry or permittivity changes: Save Config and restart the app")
|
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry changes: Save Config and restart the app")
|
||||||
owner._gpr_geometry_hint.setWordWrap(True)
|
owner._gpr_geometry_hint.setWordWrap(True)
|
||||||
owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint)
|
owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint)
|
||||||
|
|
||||||
@@ -240,11 +243,15 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_range_comp_power.setSingleStep(0.01)
|
owner._gpr_range_comp_power.setSingleStep(0.01)
|
||||||
owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power))
|
owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power))
|
||||||
|
|
||||||
owner._gpr_angle_comp_power = QDoubleSpinBox()
|
owner._gpr_object_min_frac = QDoubleSpinBox()
|
||||||
owner._gpr_angle_comp_power.setDecimals(3)
|
owner._gpr_object_min_frac.setDecimals(2)
|
||||||
owner._gpr_angle_comp_power.setRange(0.0, 5.0)
|
owner._gpr_object_min_frac.setRange(0.0, 1.0)
|
||||||
owner._gpr_angle_comp_power.setSingleStep(0.01)
|
owner._gpr_object_min_frac.setSingleStep(0.05)
|
||||||
owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power))
|
owner._gpr_object_min_frac.setToolTip(
|
||||||
|
"Object detection stops once a peak falls below this fraction of the global "
|
||||||
|
"maximum (Horns_motion_3libre.py BP_OBJECT_MIN_FRAC)."
|
||||||
|
)
|
||||||
|
owner._gpr_object_min_frac.setValue(float(gpr_live_defaults.object_min_frac))
|
||||||
|
|
||||||
owner._gpr_score_mode = QComboBox()
|
owner._gpr_score_mode = QComboBox()
|
||||||
owner._gpr_score_mode.addItems(["peak", "combined"])
|
owner._gpr_score_mode.addItems(["peak", "combined"])
|
||||||
@@ -300,6 +307,14 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_draw_top_m_objects.setRange(0, 10_000)
|
owner._gpr_draw_top_m_objects.setRange(0, 10_000)
|
||||||
owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects))
|
owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects))
|
||||||
|
|
||||||
|
owner._gpr_object_approach_min_frames = QSpinBox()
|
||||||
|
owner._gpr_object_approach_min_frames.setRange(1, 100)
|
||||||
|
owner._gpr_object_approach_min_frames.setToolTip(
|
||||||
|
"Show an object only after it persists as a motion-consistent track this many "
|
||||||
|
"consecutive frames (1 disables the approach filter)."
|
||||||
|
)
|
||||||
|
owner._gpr_object_approach_min_frames.setValue(int(gpr_live_defaults.object_approach_min_frames))
|
||||||
|
|
||||||
owner._gpr_start_freq_mhz = QDoubleSpinBox()
|
owner._gpr_start_freq_mhz = QDoubleSpinBox()
|
||||||
owner._gpr_start_freq_mhz.setDecimals(1)
|
owner._gpr_start_freq_mhz.setDecimals(1)
|
||||||
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
|
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
|
||||||
@@ -326,12 +341,6 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
|
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
|
||||||
owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode)
|
owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode)
|
||||||
|
|
||||||
owner._gpr_min_visible_score = QDoubleSpinBox()
|
|
||||||
owner._gpr_min_visible_score.setDecimals(2)
|
|
||||||
owner._gpr_min_visible_score.setRange(0.0, 1.0)
|
|
||||||
owner._gpr_min_visible_score.setSingleStep(0.05)
|
|
||||||
owner._gpr_min_visible_score.setValue(float(gpr_live_defaults.min_visible_score))
|
|
||||||
|
|
||||||
owner._gpr_visible_x_min_m = QDoubleSpinBox()
|
owner._gpr_visible_x_min_m = QDoubleSpinBox()
|
||||||
owner._gpr_visible_x_min_m.setDecimals(2)
|
owner._gpr_visible_x_min_m.setDecimals(2)
|
||||||
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
|
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
|
||||||
@@ -373,7 +382,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
("Min depth m", owner._gpr_min_depth_m),
|
("Min depth m", owner._gpr_min_depth_m),
|
||||||
("Max depth m", owner._gpr_max_depth_m),
|
("Max depth m", owner._gpr_max_depth_m),
|
||||||
("Range comp power", owner._gpr_range_comp_power),
|
("Range comp power", owner._gpr_range_comp_power),
|
||||||
("Angle comp power", owner._gpr_angle_comp_power),
|
("Object min frac", owner._gpr_object_min_frac),
|
||||||
("Score mode", owner._gpr_score_mode),
|
("Score mode", owner._gpr_score_mode),
|
||||||
("Motion mode", owner._gpr_motion_mode),
|
("Motion mode", owner._gpr_motion_mode),
|
||||||
("Look angle deg", owner._gpr_look_angle_deg),
|
("Look angle deg", owner._gpr_look_angle_deg),
|
||||||
@@ -381,9 +390,9 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_ignore_socket_speed_enabled,
|
owner._gpr_ignore_socket_speed_enabled,
|
||||||
("Speed m/s", owner._gpr_speed_m_s),
|
("Speed m/s", owner._gpr_speed_m_s),
|
||||||
("Render mode", owner._gpr_render_mode),
|
("Render mode", owner._gpr_render_mode),
|
||||||
("Min visible score", owner._gpr_min_visible_score),
|
|
||||||
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
|
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
|
||||||
("Draw top M objects", owner._gpr_draw_top_m_objects),
|
("Draw top M objects", owner._gpr_draw_top_m_objects),
|
||||||
|
("Approach min frames", owner._gpr_object_approach_min_frames),
|
||||||
("Start MHz", owner._gpr_start_freq_mhz),
|
("Start MHz", owner._gpr_start_freq_mhz),
|
||||||
("Stop MHz", owner._gpr_stop_freq_mhz),
|
("Stop MHz", owner._gpr_stop_freq_mhz),
|
||||||
("Imaging plane Y m", owner._gpr_imaging_plane_y_m),
|
("Imaging plane Y m", owner._gpr_imaging_plane_y_m),
|
||||||
@@ -533,6 +542,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._processing_mode_pages,
|
owner._processing_mode_pages,
|
||||||
[
|
[
|
||||||
("Config mode", owner._legacy_gpr_config_mode),
|
("Config mode", owner._legacy_gpr_config_mode),
|
||||||
|
("Relative permittivity", owner._gpr_relative_permittivity),
|
||||||
("Input positions", owner._legacy_gpr_input_positions_input),
|
("Input positions", owner._legacy_gpr_input_positions_input),
|
||||||
("Output positions", owner._legacy_gpr_output_positions_input),
|
("Output positions", owner._legacy_gpr_output_positions_input),
|
||||||
("Min depth m", owner._legacy_gpr_min_depth_m),
|
("Min depth m", owner._legacy_gpr_min_depth_m),
|
||||||
@@ -586,7 +596,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_angle_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_object_min_frac.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
@@ -600,9 +610,9 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
|
||||||
owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
|
||||||
owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
||||||
owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
||||||
|
owner._gpr_object_approach_min_frames.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
|
|||||||
@@ -86,12 +86,38 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
|||||||
"delay_time": variation.delay_time,
|
"delay_time": variation.delay_time,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_write_variation_session(variation)
|
||||||
return True
|
return True
|
||||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||||
finally:
|
finally:
|
||||||
controller.disconnect()
|
controller.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_variation_session(variation) -> None:
|
||||||
|
"""Freeze the variation's static temperature targets for the checker.
|
||||||
|
|
||||||
|
Best-effort: a failure to write the session snapshot must never abort the
|
||||||
|
acquisition setup, so any error is logged and swallowed.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from python_app.hardware_full.laser_control.monitoring.session import (
|
||||||
|
LaserVariationSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
LaserVariationSession(
|
||||||
|
variation_type=variation.variation_type,
|
||||||
|
target_temp1=variation.static_temp1,
|
||||||
|
target_temp2=variation.static_temp2,
|
||||||
|
tolerance_c=variation.temp_tolerance_c,
|
||||||
|
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
||||||
|
).save()
|
||||||
|
logger.debug("Wrote laser variation session snapshot for the temperature checker")
|
||||||
|
except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal
|
||||||
|
logger.warning("Failed to write laser variation session snapshot", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
||||||
laser = config.radar.laser_control
|
laser = config.radar.laser_control
|
||||||
if not laser.port:
|
if not laser.port:
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ import numpy as np
|
|||||||
FRAME_BYTES = 8
|
FRAME_BYTES = 8
|
||||||
MAIN_MARKER = 0x000A
|
MAIN_MARKER = 0x000A
|
||||||
REFERENCE_MARKER = 0x00A8
|
REFERENCE_MARKER = 0x00A8
|
||||||
|
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
|
||||||
|
# collector right after a sweep boundary to label the upcoming sweep with the RF
|
||||||
|
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
|
||||||
|
# a switch transition and must be dropped). Absent in the standalone/calibration
|
||||||
|
# collector, where every sweep carries no combo (``combo is None``).
|
||||||
|
COMBO_MARKER = 0x00C0
|
||||||
_BOUNDARY_STEP = 0xFFFF
|
_BOUNDARY_STEP = 0xFFFF
|
||||||
|
|
||||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||||
@@ -52,6 +58,8 @@ class RawSweep:
|
|||||||
steps: np.ndarray
|
steps: np.ndarray
|
||||||
main: np.ndarray
|
main: np.ndarray
|
||||||
reference: np.ndarray
|
reference: np.ndarray
|
||||||
|
combo: tuple[int, int] | None = None
|
||||||
|
dirty: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self) -> int:
|
def size(self) -> int:
|
||||||
@@ -66,13 +74,17 @@ class KamilAdcStreamParser:
|
|||||||
but holds no I/O and is cheap to unit-test.
|
but holds no I/O and is cheap to unit-test.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
|
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
self._aligned = False
|
self._aligned = False
|
||||||
self._main: dict[int, complex] = {}
|
self._main: dict[int, complex] = {}
|
||||||
self._reference: dict[int, complex] = {}
|
self._reference: dict[int, complex] = {}
|
||||||
|
# Combo tag for the sweep currently being accumulated (set by the combo
|
||||||
|
# frame right after each boundary; ``None`` in non-switch collector modes).
|
||||||
|
self._pending_combo: tuple[int, int] | None = None
|
||||||
|
self._pending_dirty = False
|
||||||
|
|
||||||
def feed(self, data: bytes) -> list[RawSweep]:
|
def feed(self, data: bytes) -> list[RawSweep]:
|
||||||
"""Append ``data`` and return any sweeps completed by it."""
|
"""Append ``data`` and return any sweeps completed by it."""
|
||||||
@@ -95,6 +107,10 @@ class KamilAdcStreamParser:
|
|||||||
self._main[step] = complex(real, imag)
|
self._main[step] = complex(real, imag)
|
||||||
elif marker == REFERENCE_MARKER:
|
elif marker == REFERENCE_MARKER:
|
||||||
self._reference[step] = complex(real, imag)
|
self._reference[step] = complex(real, imag)
|
||||||
|
elif marker == COMBO_MARKER:
|
||||||
|
# step = input_pos, real = output_pos, imag = dirty flag.
|
||||||
|
self._pending_combo = (int(step), int(real))
|
||||||
|
self._pending_dirty = imag != 0
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||||
@@ -107,6 +123,8 @@ class KamilAdcStreamParser:
|
|||||||
self._aligned = False
|
self._aligned = False
|
||||||
self._main.clear()
|
self._main.clear()
|
||||||
self._reference.clear()
|
self._reference.clear()
|
||||||
|
self._pending_combo = None
|
||||||
|
self._pending_dirty = False
|
||||||
|
|
||||||
def _align(self) -> bool:
|
def _align(self) -> bool:
|
||||||
"""Discard pre-roll up to and including the first sweep boundary.
|
"""Discard pre-roll up to and including the first sweep boundary.
|
||||||
@@ -122,6 +140,8 @@ class KamilAdcStreamParser:
|
|||||||
del self._buffer[: index + FRAME_BYTES]
|
del self._buffer[: index + FRAME_BYTES]
|
||||||
self._main.clear()
|
self._main.clear()
|
||||||
self._reference.clear()
|
self._reference.clear()
|
||||||
|
self._pending_combo = None
|
||||||
|
self._pending_dirty = False
|
||||||
self._aligned = True
|
self._aligned = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -130,12 +150,21 @@ class KamilAdcStreamParser:
|
|||||||
shared = sorted(self._main.keys() & self._reference.keys())
|
shared = sorted(self._main.keys() & self._reference.keys())
|
||||||
main = self._main
|
main = self._main
|
||||||
reference = self._reference
|
reference = self._reference
|
||||||
|
combo = self._pending_combo
|
||||||
|
dirty = self._pending_dirty
|
||||||
self._main = {}
|
self._main = {}
|
||||||
self._reference = {}
|
self._reference = {}
|
||||||
|
# The next sweep's combo is set by its own combo frame (right after this
|
||||||
|
# boundary); clear so a sweep without one reports combo=None rather than
|
||||||
|
# inheriting a stale tag.
|
||||||
|
self._pending_combo = None
|
||||||
|
self._pending_dirty = False
|
||||||
if not shared:
|
if not shared:
|
||||||
return None
|
return None
|
||||||
return RawSweep(
|
return RawSweep(
|
||||||
steps=np.asarray(shared, dtype=np.int32),
|
steps=np.asarray(shared, dtype=np.int32),
|
||||||
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||||
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||||
|
combo=combo,
|
||||||
|
dirty=dirty,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ _REJECT_LOG_EVERY = 50
|
|||||||
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
||||||
# the configured stop_timeout_s so a stop can never hang.
|
# the configured stop_timeout_s so a stop can never hang.
|
||||||
_STOP_KILL_GRACE_S = 0.5
|
_STOP_KILL_GRACE_S = 0.5
|
||||||
|
# Sweeps to skip after a Python-driven switch change before trusting a capture: one
|
||||||
|
# for the pre-switch sweep still in the mailbox, one for a possible transition
|
||||||
|
# straddler in flight. Calibration speed is not critical, so we err on safety.
|
||||||
|
_SWITCH_DRAIN_SWEEPS = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -54,6 +58,10 @@ class KamilAdcService:
|
|||||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
||||||
|
|
||||||
config: RunConfigModel
|
config: RunConfigModel
|
||||||
|
# When set, the collector is launched with ``config:<path>`` so it drives the RF
|
||||||
|
# switches itself (switch-aware mode) from this run_config.json. ``None`` keeps
|
||||||
|
# the standalone collector that streams a single channel (calibration / mock).
|
||||||
|
switch_config_path: str | None = None
|
||||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||||
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
|
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
|
||||||
@@ -64,9 +72,18 @@ class KamilAdcService:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> list[str]:
|
def command(self) -> list[str]:
|
||||||
"""External collector command, including the generated ``tty:`` argument."""
|
"""External collector command, including the generated ``tty:`` argument.
|
||||||
|
|
||||||
|
In switch-aware mode (``switch_config_path`` set) the collector also gets
|
||||||
|
``config:<path>`` so it reads the switch/combo configuration and drives the
|
||||||
|
switches in lock-step with the sweeps.
|
||||||
|
"""
|
||||||
adc = self.config.radar.kamil_adc
|
adc = self.config.radar.kamil_adc
|
||||||
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
|
cmd = [str(self._resolve_executable()), *adc.args]
|
||||||
|
if self.switch_config_path is not None:
|
||||||
|
cmd.append(f"config:{self.switch_config_path}")
|
||||||
|
cmd.append(f"tty:{adc.tty_path}")
|
||||||
|
return cmd
|
||||||
|
|
||||||
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
||||||
"""Launch the collector and start the TTY reader thread.
|
"""Launch the collector and start the TTY reader thread.
|
||||||
@@ -122,12 +139,17 @@ class KamilAdcService:
|
|||||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
||||||
raise RuntimeError("Kamil ADC device limits are not available")
|
raise RuntimeError("Kamil ADC device limits are not available")
|
||||||
|
|
||||||
def acquire(self) -> SweepResult:
|
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
|
||||||
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
||||||
|
|
||||||
Sweeps whose floated frequency range does not span the configured band are
|
Sweeps whose floated frequency range does not span the configured band are
|
||||||
rejected and the next sweep is read, until one passes or the sweep timeout
|
rejected and the next sweep is read, until one passes or the sweep timeout
|
||||||
elapses (which then surfaces as a :class:`TimeoutError`).
|
elapses (which then surfaces as a :class:`TimeoutError`).
|
||||||
|
|
||||||
|
When ``combo`` is given (switch-aware mode), only the clean sweep captured
|
||||||
|
under that switch combination is returned; the collector drives the switches
|
||||||
|
and tags each sweep. When ``None`` (calibration / non-switch mode), the
|
||||||
|
single newest sweep is returned regardless of combination.
|
||||||
"""
|
"""
|
||||||
if self._processor is None:
|
if self._processor is None:
|
||||||
raise RuntimeError("Kamil ADC service is not configured")
|
raise RuntimeError("Kamil ADC service is not configured")
|
||||||
@@ -147,7 +169,10 @@ class KamilAdcService:
|
|||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
||||||
)
|
)
|
||||||
|
if combo is None:
|
||||||
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
|
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
|
||||||
|
else:
|
||||||
|
raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process)
|
||||||
s21 = self._processor.process(raw.main, raw.reference)
|
s21 = self._processor.process(raw.main, raw.reference)
|
||||||
if s21 is not None:
|
if s21 is not None:
|
||||||
return SweepResult(
|
return SweepResult(
|
||||||
@@ -159,6 +184,31 @@ class KamilAdcService:
|
|||||||
)
|
)
|
||||||
self._log_rejected_sweep(raw)
|
self._log_rejected_sweep(raw)
|
||||||
|
|
||||||
|
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
|
||||||
|
"""Discard sweeps captured before / across a just-applied switch change.
|
||||||
|
|
||||||
|
The collector free-runs, so right after the RF switches move the reader
|
||||||
|
still holds a sweep captured in the *previous* combination, and a sweep that
|
||||||
|
straddles the transition may still be in flight. Without this, the next
|
||||||
|
:meth:`acquire` would return that stale data and the capture would be
|
||||||
|
attributed to the wrong combination (an off-by-one across the sequence).
|
||||||
|
|
||||||
|
Block until ``sweeps`` freshly-published sweeps have gone by, so the next
|
||||||
|
:meth:`acquire` returns a sweep captured entirely in the new switch state.
|
||||||
|
Used by the Python-driven calibration capture, where the collector does not
|
||||||
|
tag sweeps; the switch-aware collector path handles this with combo tags
|
||||||
|
instead. No-op when the service is not open.
|
||||||
|
"""
|
||||||
|
if self._reader is None:
|
||||||
|
return
|
||||||
|
target = self._reader.published_count + max(1, int(sweeps))
|
||||||
|
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
||||||
|
while self._reader.published_count < target:
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
|
||||||
|
raise_if_process_exited(self._process)
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
def read_raw_sweep(self) -> RawSweep:
|
def read_raw_sweep(self) -> RawSweep:
|
||||||
"""Return the next raw (main, reference) sweep without any processing.
|
"""Return the next raw (main, reference) sweep without any processing.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class KamilAdcTtyReader:
|
|||||||
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
||||||
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
||||||
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
||||||
|
# Latest clean sweep per switch combination, for the switch-aware collector.
|
||||||
|
# read_sweep() ignores this and serves the single newest sweep (calibration /
|
||||||
|
# non-switch mode); read_sweep_for() serves a specific combination.
|
||||||
|
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
|
||||||
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||||
_published_count: int = field(init=False, default=0, repr=False)
|
_published_count: int = field(init=False, default=0, repr=False)
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ class KamilAdcTtyReader:
|
|||||||
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
self._latest_sweep = None
|
self._latest_sweep = None
|
||||||
|
self._combo_slots = {}
|
||||||
self._reader_error = None
|
self._reader_error = None
|
||||||
self._published_count = 0
|
self._published_count = 0
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
@@ -89,6 +94,7 @@ class KamilAdcTtyReader:
|
|||||||
finally:
|
finally:
|
||||||
self._fd = None
|
self._fd = None
|
||||||
self._latest_sweep = None
|
self._latest_sweep = None
|
||||||
|
self._combo_slots = {}
|
||||||
self._reader_error = None
|
self._reader_error = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -131,6 +137,39 @@ class KamilAdcTtyReader:
|
|||||||
)
|
)
|
||||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||||
|
|
||||||
|
def read_sweep_for(
|
||||||
|
self,
|
||||||
|
combo: tuple[int, int],
|
||||||
|
*,
|
||||||
|
timeout_s: float,
|
||||||
|
process: subprocess.Popen[bytes] | None = None,
|
||||||
|
) -> RawSweep:
|
||||||
|
"""Wait for and return the latest clean sweep for ``combo``.
|
||||||
|
|
||||||
|
Used in switch-aware mode, where the collector drives the switches and tags
|
||||||
|
each sweep with its combination. Only clean sweeps are delivered (the reader
|
||||||
|
thread drops the dirty ones); the slot is consumed on read so each caller
|
||||||
|
gets a fresh capture. Raises like :meth:`read_sweep`.
|
||||||
|
"""
|
||||||
|
if self._thread is None:
|
||||||
|
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||||
|
deadline = time.monotonic() + float(timeout_s)
|
||||||
|
with self._mailbox_cv:
|
||||||
|
while True:
|
||||||
|
sweep = self._combo_slots.pop(combo, None)
|
||||||
|
if sweep is not None:
|
||||||
|
return sweep
|
||||||
|
if self._reader_error is not None:
|
||||||
|
raise self._reader_error
|
||||||
|
raise_if_process_exited(process)
|
||||||
|
remaining_s = deadline - time.monotonic()
|
||||||
|
if remaining_s <= 0.0:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
|
||||||
|
f"after {float(timeout_s):.3f}s"
|
||||||
|
)
|
||||||
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Reader-thread internals
|
# Reader-thread internals
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -177,11 +216,22 @@ class KamilAdcTtyReader:
|
|||||||
return chunk
|
return chunk
|
||||||
|
|
||||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||||
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
|
"""Publish a completed sweep to the mailbox(es), waking any waiter.
|
||||||
|
|
||||||
|
Dirty sweeps (those that straddled a switch transition) are counted but not
|
||||||
|
delivered: the collector re-takes that combination on the next sweep. Clean
|
||||||
|
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
|
||||||
|
only update the single newest-sweep mailbox that read_sweep() serves.
|
||||||
|
"""
|
||||||
with self._mailbox_cv:
|
with self._mailbox_cv:
|
||||||
self._latest_sweep = sweep
|
|
||||||
self._published_count += 1
|
self._published_count += 1
|
||||||
self._mailbox_cv.notify()
|
if sweep.dirty:
|
||||||
|
self._mailbox_cv.notify_all()
|
||||||
|
return
|
||||||
|
self._latest_sweep = sweep
|
||||||
|
if sweep.combo is not None:
|
||||||
|
self._combo_slots[sweep.combo] = sweep
|
||||||
|
self._mailbox_cv.notify_all()
|
||||||
|
|
||||||
def _publish_error(self, exc: Exception) -> None:
|
def _publish_error(self, exc: Exception) -> None:
|
||||||
"""Record ``exc`` as the reader fault and wake any waiter."""
|
"""Record ``exc`` as the reader fault and wake any waiter."""
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Контроль температуры при вариации тока лазера
|
||||||
|
|
||||||
|
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
|
||||||
|
**вариации тока лазера 1** (`CHANGE_CURRENT_LD1`). Пока плата гоняет свип тока,
|
||||||
|
температуры лазеров должны оставаться на заданных статичных уставках. Эти модули
|
||||||
|
раз в свип считывают реальную температуру и предупреждают, если она разошлась с
|
||||||
|
целью.
|
||||||
|
|
||||||
|
## Зачем это нужно
|
||||||
|
|
||||||
|
При запуске вариации тока из GUI изменённые значения температуры могут фактически
|
||||||
|
не дойти до цели — реальная температура остаётся прежней, и измерение становится
|
||||||
|
некорректным. Плата после старта задачи гоняет свип **автономно** и никак не
|
||||||
|
сигнализирует, что уставка не достигнута. Эти модули закрывают пробел: независимо
|
||||||
|
опрашивают плату и валидируют температуру относительно уставок, зафиксированных
|
||||||
|
**в момент старта вариации**.
|
||||||
|
|
||||||
|
> ⚠️ В прошивке реализована только **вариация тока** (`CHANGE_CURRENT_LD1`).
|
||||||
|
> Вариация температуры не поддерживается и в этот API не заложена.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
[starter] ── TASK_ENABLE ──► плата кратко открыл порт, послал, закрыл
|
||||||
|
│ пишет session.json (target temp1/2, tolerance, variation_type)
|
||||||
|
▼
|
||||||
|
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
|
||||||
|
│ раз в свип: get_measurements()
|
||||||
|
│ дописывает строку в readings.jsonl (seq, temp1, temp2, temp_ext, I1, I2)
|
||||||
|
▼
|
||||||
|
[checker] читает session.json + tail readings.jsonl
|
||||||
|
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Порт лазера эксклюзивен.** `starter` трогает его кратко, затем `monitor`
|
||||||
|
владеет им всё время. `checker` порт не трогает вовсе — читает только файлы.
|
||||||
|
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
|
||||||
|
останавливаются и перезапускаются независимо, без рукопожатия.
|
||||||
|
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
|
||||||
|
термисторам `temp_ext*`), каждый со своим допуском (по умолчанию `0.03 °C`).
|
||||||
|
|
||||||
|
## Быстрый старт (CLI, два процесса)
|
||||||
|
|
||||||
|
Терминал 1 — стартовать вариацию и мониторить температуру:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_monitor \
|
||||||
|
--config run_config.json \
|
||||||
|
--start
|
||||||
|
```
|
||||||
|
|
||||||
|
Терминал 2 — валидировать температуру и печатать предупреждения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_checker
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример вывода чекера при расхождении и возврате в допуск:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING laser_temp_checker: Laser 1 temperature off target: measured 28.100 °C,
|
||||||
|
target 28.000 °C, Δ=+0.100 °C exceeds tolerance ±0.030 °C [seq=1]
|
||||||
|
INFO laser_temp_checker: Laser 1 temperature back within tolerance:
|
||||||
|
28.000 °C (target 28.000, |Δ|=0.000 ≤ 0.030) [seq=2]
|
||||||
|
```
|
||||||
|
|
||||||
|
Остановка — `Ctrl+C` (SIGINT) в любом из процессов.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
|
||||||
|
использует блок `variation` и новое поле `temp_tolerance_c`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "kamil_adc",
|
||||||
|
"laser_control": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": "/dev/ttyUSB0",
|
||||||
|
"mode": "variation",
|
||||||
|
"pi_coeff1_p": 2560,
|
||||||
|
"pi_coeff1_i": 128,
|
||||||
|
"pi_coeff2_p": 2560,
|
||||||
|
"pi_coeff2_i": 128,
|
||||||
|
"variation": {
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"static_temp1": 28.0,
|
||||||
|
"static_temp2": 28.9,
|
||||||
|
"static_current1": 33.0,
|
||||||
|
"static_current2": 35.0,
|
||||||
|
"min_value": 33.0,
|
||||||
|
"max_value": 60.0,
|
||||||
|
"step": 0.05,
|
||||||
|
"time_step": 50,
|
||||||
|
"delay_time": 10,
|
||||||
|
"temp_tolerance_c": 0.03
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые поля для мониторинга:
|
||||||
|
|
||||||
|
| Поле | Смысл |
|
||||||
|
|---|---|
|
||||||
|
| `port` | Серийный порт лазерной платы (пусто → автоопределение) |
|
||||||
|
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
|
||||||
|
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
|
||||||
|
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
|
||||||
|
| `temp_tolerance_c` | Допуск сверки, °C (по умолчанию `0.03`) |
|
||||||
|
|
||||||
|
## Опции CLI
|
||||||
|
|
||||||
|
### `laser_temp_monitor`
|
||||||
|
|
||||||
|
| Аргумент | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `--config` | — (обязателен) | Путь к `run_config.json` |
|
||||||
|
| `--start` | выкл. | Послать `CHANGE_CURRENT_LD1` перед мониторингом и записать сессию |
|
||||||
|
| `--strategy` | `computed` | `computed` (раз в свип) или `interval:<ms>` (фикс. период) |
|
||||||
|
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Куда дописывать показания |
|
||||||
|
| `--session` | `<tmp>/laser_variation_session.json` | Куда писать снимок сессии (с `--start`) |
|
||||||
|
|
||||||
|
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Фиксированный период вместо расчётного (напр. раз в 500 мс):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_monitor \
|
||||||
|
--config run_config.json --strategy interval:500
|
||||||
|
```
|
||||||
|
|
||||||
|
### `laser_temp_checker`
|
||||||
|
|
||||||
|
| Аргумент | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `--session` | `<tmp>/laser_variation_session.json` | Снимок с целями и допуском |
|
||||||
|
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Какой канал показаний тайлить |
|
||||||
|
| `--tolerance` | из сессии | Переопределить допуск, °C |
|
||||||
|
| `--reminder-every` | `0` (выкл.) | Повторять предупреждение каждые N показаний, пока вне допуска |
|
||||||
|
| `--from-start` | выкл. | Проверить весь файл показаний, а не только новые строки |
|
||||||
|
|
||||||
|
Разные пути для нескольких одновременных прогонов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# монитор
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
|
||||||
|
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
|
||||||
|
# чекер
|
||||||
|
python -m python_app.scripts.laser_temp_checker \
|
||||||
|
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Интеграция с пайплайном Kamil ADC
|
||||||
|
|
||||||
|
Когда вариацию стартует штатный пайплайн
|
||||||
|
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
|
||||||
|
`session.json` пишется автоматически. Достаточно запустить только чекер
|
||||||
|
(и, при желании, монитор без `--start`, чтобы он опрашивал плату). Так консоль
|
||||||
|
получит предупреждения о рассинхроне температуры прямо во время захвата.
|
||||||
|
|
||||||
|
## Встраивание в свой код (без CLI)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import threading
|
||||||
|
from python_app.hardware_full.laser_control.controller import LaserController
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
LaserTemperatureMonitor, LaserTemperatureChecker, LaserVariationSession,
|
||||||
|
ReadingWriter, ReadingReader, resolve_period_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Зафиксировать цели при старте вариации
|
||||||
|
session = LaserVariationSession(
|
||||||
|
variation_type="CHANGE_CURRENT_LD1",
|
||||||
|
target_temp1=28.0, target_temp2=28.9, tolerance_c=0.03,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Монитор (в проде controller — реальный LaserController)
|
||||||
|
period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
|
||||||
|
time_step_us=50, delay_time_ms=10)
|
||||||
|
stop = threading.Event()
|
||||||
|
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
|
||||||
|
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
|
||||||
|
threading.Thread(target=monitor.run, args=(stop,), daemon=True).start()
|
||||||
|
|
||||||
|
# 3. Чекер: тайлить показания и валидировать оба лазера
|
||||||
|
checker = LaserTemperatureChecker.from_session(session)
|
||||||
|
reader = ReadingReader("readings.jsonl")
|
||||||
|
while not stop.is_set():
|
||||||
|
for reading in reader.poll():
|
||||||
|
checker.process(reading) # печатает WARNING при |Δ| > tolerance
|
||||||
|
stop.wait(0.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
`LaserTemperatureChecker.evaluate(reading)` возвращает список
|
||||||
|
`LaserDeviation` (по лазеру: измеренное, цель, Δ, в допуске ли) без логирования —
|
||||||
|
удобно для собственной обработки/накопления статистики.
|
||||||
|
|
||||||
|
## Формат IPC-файлов
|
||||||
|
|
||||||
|
`session.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"target_temp1": 28.0,
|
||||||
|
"target_temp2": 28.9,
|
||||||
|
"tolerance_c": 0.03,
|
||||||
|
"started_at_iso": "2026-07-27T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`readings.jsonl` (по одной строке-объекту на свип):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"seq":0,"mono_ns":123456789,"temp1":28.0,"temp2":28.9,"temp_ext1":22.0,"temp_ext2":23.0,"current1":33.0,"current2":35.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как определяется «раз в свип»
|
||||||
|
|
||||||
|
Плата не отдаёт явную границу свипа, поэтому период оценивается из параметров:
|
||||||
|
|
||||||
|
```
|
||||||
|
num_steps = round(|max_value - min_value| / step) + 1
|
||||||
|
per_point_s = delay_time / 1000 + time_step / 1_000_000
|
||||||
|
sweep_period = num_steps × per_point_s
|
||||||
|
```
|
||||||
|
|
||||||
|
Монитор публикует одно показание за такой период. Если нужен другой темп —
|
||||||
|
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
|
||||||
|
семантика не гарантирована, поэтому для тайминга он не используется.)
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest python_app/tests/test_laser_temp_monitoring.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
|
||||||
|
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
|
||||||
|
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Laser current-variation temperature monitoring and validation.
|
||||||
|
|
||||||
|
Three decoupled pieces connected via IPC files:
|
||||||
|
- :class:`LaserVariationSession` — target setpoints + tolerance frozen at start.
|
||||||
|
- :class:`LaserTemperatureMonitor` — polls the board once per sweep, publishes.
|
||||||
|
- :class:`LaserTemperatureChecker` — validates published readings, warns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .checker import LaserDeviation, LaserTemperatureChecker
|
||||||
|
from .monitor import (
|
||||||
|
LaserTemperatureMonitor,
|
||||||
|
compute_sweep_period_s,
|
||||||
|
resolve_period_s,
|
||||||
|
)
|
||||||
|
from .readings_channel import ReadingReader, ReadingWriter, TemperatureReading
|
||||||
|
from .session import (
|
||||||
|
DEFAULT_READINGS_PATH,
|
||||||
|
DEFAULT_SESSION_PATH,
|
||||||
|
DEFAULT_TOLERANCE_C,
|
||||||
|
LaserVariationSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LaserDeviation",
|
||||||
|
"LaserTemperatureChecker",
|
||||||
|
"LaserTemperatureMonitor",
|
||||||
|
"compute_sweep_period_s",
|
||||||
|
"resolve_period_s",
|
||||||
|
"ReadingReader",
|
||||||
|
"ReadingWriter",
|
||||||
|
"TemperatureReading",
|
||||||
|
"DEFAULT_READINGS_PATH",
|
||||||
|
"DEFAULT_SESSION_PATH",
|
||||||
|
"DEFAULT_TOLERANCE_C",
|
||||||
|
"LaserVariationSession",
|
||||||
|
]
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Independent laser temperature checker.
|
||||||
|
|
||||||
|
Reads the target setpoints frozen at variation start (:class:`LaserVariationSession`)
|
||||||
|
and validates each published :class:`TemperatureReading` against them. Both lasers
|
||||||
|
are checked independently: ``temp1`` against ``target_temp1`` and ``temp2`` against
|
||||||
|
``target_temp2``. When a laser's measured temperature deviates from its target by
|
||||||
|
more than the tolerance (default 0.03 °C), a warning is printed to the console.
|
||||||
|
|
||||||
|
Runs as its own process (see ``scripts/laser_temp_checker.py``), reading the JSONL
|
||||||
|
readings channel — it never touches the serial port, so it is fully independent of
|
||||||
|
the monitor and can be started, stopped, or restarted at any time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .readings_channel import TemperatureReading
|
||||||
|
from .session import DEFAULT_TOLERANCE_C, LaserVariationSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Only a deviation strictly greater than the tolerance warns; this epsilon keeps a
|
||||||
|
# value the user intends to be exactly at the tolerance from tripping on float error
|
||||||
|
# (e.g. 28.03 - 28.00 == 0.030000000000001 in IEEE-754).
|
||||||
|
_FLOAT_EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaserDeviation:
|
||||||
|
"""Result of comparing one laser's measured temperature to its target."""
|
||||||
|
|
||||||
|
laser: int # 1 or 2
|
||||||
|
seq: int
|
||||||
|
measured: float
|
||||||
|
target: float
|
||||||
|
delta: float # measured - target, °C
|
||||||
|
within_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LaserTemperatureChecker:
|
||||||
|
"""Validates readings against per-laser targets and warns on mismatch.
|
||||||
|
|
||||||
|
Anti-spam: a laser's ok↔mismatch transitions are logged once; while a laser
|
||||||
|
stays out of tolerance, a reminder is emitted only every ``reminder_every``
|
||||||
|
readings (0 disables reminders). State is tracked independently per laser, so
|
||||||
|
a persistent laser-1 fault never suppresses a fresh laser-2 warning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
target_temp1: float,
|
||||||
|
target_temp2: float,
|
||||||
|
tolerance_c: float = DEFAULT_TOLERANCE_C,
|
||||||
|
reminder_every: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.target_temp1 = float(target_temp1)
|
||||||
|
self.target_temp2 = float(target_temp2)
|
||||||
|
self.tolerance_c = float(tolerance_c)
|
||||||
|
self.reminder_every = int(reminder_every)
|
||||||
|
# Per-laser state: mismatch flag + readings seen since the last log.
|
||||||
|
self._mismatch = {1: False, 2: False}
|
||||||
|
self._since_log = {1: 0, 2: 0}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_session(
|
||||||
|
cls, session: LaserVariationSession, reminder_every: int = 0
|
||||||
|
) -> "LaserTemperatureChecker":
|
||||||
|
return cls(
|
||||||
|
target_temp1=session.target_temp1,
|
||||||
|
target_temp2=session.target_temp2,
|
||||||
|
tolerance_c=session.tolerance_c,
|
||||||
|
reminder_every=reminder_every,
|
||||||
|
)
|
||||||
|
|
||||||
|
def evaluate(self, reading: TemperatureReading) -> List[LaserDeviation]:
|
||||||
|
"""Compute per-laser deviations without logging (pure)."""
|
||||||
|
return [
|
||||||
|
self._deviation(1, reading.seq, reading.temp1, self.target_temp1),
|
||||||
|
self._deviation(2, reading.seq, reading.temp2, self.target_temp2),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process(self, reading: TemperatureReading) -> List[LaserDeviation]:
|
||||||
|
"""Evaluate a reading and emit console warnings, honouring anti-spam.
|
||||||
|
|
||||||
|
Returns the deviations for which a warning/reminder was emitted this call
|
||||||
|
(empty when both lasers are within tolerance and unchanged).
|
||||||
|
"""
|
||||||
|
warned: List[LaserDeviation] = []
|
||||||
|
for dev in self.evaluate(reading):
|
||||||
|
if self._should_warn(dev):
|
||||||
|
self._warn(dev)
|
||||||
|
warned.append(dev)
|
||||||
|
return warned
|
||||||
|
|
||||||
|
def _deviation(self, laser: int, seq: int, measured: float, target: float) -> LaserDeviation:
|
||||||
|
delta = measured - target
|
||||||
|
return LaserDeviation(
|
||||||
|
laser=laser,
|
||||||
|
seq=seq,
|
||||||
|
measured=measured,
|
||||||
|
target=target,
|
||||||
|
delta=delta,
|
||||||
|
within_tolerance=abs(delta) <= self.tolerance_c + _FLOAT_EPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_warn(self, dev: LaserDeviation) -> bool:
|
||||||
|
laser = dev.laser
|
||||||
|
if not dev.within_tolerance:
|
||||||
|
if not self._mismatch[laser]:
|
||||||
|
# Fresh ok -> mismatch transition: always warn.
|
||||||
|
self._mismatch[laser] = True
|
||||||
|
self._since_log[laser] = 0
|
||||||
|
return True
|
||||||
|
# Still out of tolerance: warn again only every reminder_every readings.
|
||||||
|
self._since_log[laser] += 1
|
||||||
|
if self.reminder_every > 0 and self._since_log[laser] >= self.reminder_every:
|
||||||
|
self._since_log[laser] = 0
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# Within tolerance: log a recovery once, then stay quiet.
|
||||||
|
if self._mismatch[laser]:
|
||||||
|
self._mismatch[laser] = False
|
||||||
|
self._since_log[laser] = 0
|
||||||
|
logger.info(
|
||||||
|
"Laser %d temperature back within tolerance: %.3f °C "
|
||||||
|
"(target %.3f, |Δ|=%.3f ≤ %.3f) [seq=%d]",
|
||||||
|
laser, dev.measured, dev.target, abs(dev.delta), self.tolerance_c, dev.seq,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _warn(self, dev: LaserDeviation) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"Laser %d temperature off target: measured %.3f °C, target %.3f °C, "
|
||||||
|
"Δ=%+.3f °C exceeds tolerance ±%.3f °C [seq=%d]",
|
||||||
|
dev.laser, dev.measured, dev.target, dev.delta, self.tolerance_c, dev.seq,
|
||||||
|
)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Independent laser temperature monitor.
|
||||||
|
|
||||||
|
Owns a :class:`LaserController` connection and, once per current-variation sweep,
|
||||||
|
polls the board for a measurement and publishes it to a JSONL readings channel.
|
||||||
|
The board runs the current sweep autonomously after ``TASK_ENABLE``; the monitor
|
||||||
|
only reads the "last data point" via ``TRANS_ENABLE`` — exactly like the original
|
||||||
|
RadioPhotonic PC software's polling loop, but decoupled and headless.
|
||||||
|
|
||||||
|
Runs as its own process (see ``scripts/laser_temp_monitor.py``) so it is fully
|
||||||
|
independent of both the acquisition pipeline and the temperature checker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
from .readings_channel import ReadingWriter, TemperatureReading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _MeasurementSource(Protocol):
|
||||||
|
"""Minimal controller surface the monitor depends on (eases testing)."""
|
||||||
|
|
||||||
|
def get_measurements(self) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sweep_period_s(
|
||||||
|
min_value: float,
|
||||||
|
max_value: float,
|
||||||
|
step: float,
|
||||||
|
time_step_us: float,
|
||||||
|
delay_time_ms: float,
|
||||||
|
) -> float:
|
||||||
|
"""Estimate the duration of one min→max current sweep, in seconds.
|
||||||
|
|
||||||
|
``num_steps = round(|max - min| / step) + 1`` points, each taking roughly the
|
||||||
|
inter-point delay plus the discretisation time. The board gives no explicit
|
||||||
|
end-of-sweep marker, so this computed period is how "once per sweep" is timed
|
||||||
|
by default.
|
||||||
|
"""
|
||||||
|
if step <= 0:
|
||||||
|
raise ValueError(f"step must be > 0, got {step}")
|
||||||
|
span = abs(max_value - min_value)
|
||||||
|
num_steps = round(span / step) + 1
|
||||||
|
per_point_s = delay_time_ms / 1000.0 + time_step_us / 1_000_000.0
|
||||||
|
return num_steps * per_point_s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_period_s(
|
||||||
|
strategy: str,
|
||||||
|
*,
|
||||||
|
min_value: float,
|
||||||
|
max_value: float,
|
||||||
|
step: float,
|
||||||
|
time_step_us: float,
|
||||||
|
delay_time_ms: float,
|
||||||
|
) -> float:
|
||||||
|
"""Turn a strategy string into a concrete per-reading period in seconds.
|
||||||
|
|
||||||
|
Supported strategies:
|
||||||
|
- ``"computed"`` — one reading per estimated sweep duration (default).
|
||||||
|
- ``"interval:<ms>"`` — a fixed period of ``<ms>`` milliseconds.
|
||||||
|
"""
|
||||||
|
if strategy == "computed":
|
||||||
|
return compute_sweep_period_s(min_value, max_value, step, time_step_us, delay_time_ms)
|
||||||
|
if strategy.startswith("interval:"):
|
||||||
|
try:
|
||||||
|
ms = float(strategy.split(":", 1)[1])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid interval strategy {strategy!r}") from exc
|
||||||
|
if ms <= 0:
|
||||||
|
raise ValueError(f"interval must be > 0 ms, got {ms}")
|
||||||
|
return ms / 1000.0
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown strategy {strategy!r}; expected 'computed' or 'interval:<ms>'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaserTemperatureMonitor:
|
||||||
|
"""Polls a laser board once per sweep and publishes temperature readings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
controller: object exposing ``get_measurements()`` (a real
|
||||||
|
:class:`LaserController` in production, a fake in tests).
|
||||||
|
writer: destination channel implementing ``write(TemperatureReading)``.
|
||||||
|
period_s: seconds between readings (see :func:`resolve_period_s`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
controller: _MeasurementSource
|
||||||
|
writer: ReadingWriter
|
||||||
|
period_s: float
|
||||||
|
|
||||||
|
def read_once(self, seq: int) -> Optional[TemperatureReading]:
|
||||||
|
"""Poll one measurement and turn it into a reading, or None if no data."""
|
||||||
|
measurements = self.controller.get_measurements()
|
||||||
|
if measurements is None:
|
||||||
|
logger.warning("No measurement returned from laser board (seq=%d)", seq)
|
||||||
|
return None
|
||||||
|
return TemperatureReading(
|
||||||
|
seq=seq,
|
||||||
|
mono_ns=time.monotonic_ns(),
|
||||||
|
temp1=float(measurements.temp1),
|
||||||
|
temp2=float(measurements.temp2),
|
||||||
|
temp_ext1=_opt(getattr(measurements, "temp_ext1", None)),
|
||||||
|
temp_ext2=_opt(getattr(measurements, "temp_ext2", None)),
|
||||||
|
current1=_opt(getattr(measurements, "current1", None)),
|
||||||
|
current2=_opt(getattr(measurements, "current2", None)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, stop_event: Optional[threading.Event] = None) -> None:
|
||||||
|
"""Poll-and-publish until ``stop_event`` is set (runs forever if None).
|
||||||
|
|
||||||
|
Each iteration reads once, publishes, then waits one period. The wait is
|
||||||
|
interruptible via ``stop_event`` for a prompt clean shutdown.
|
||||||
|
"""
|
||||||
|
stop = stop_event or threading.Event()
|
||||||
|
seq = 0
|
||||||
|
logger.info("Temperature monitor started: period=%.3fs", self.period_s)
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
reading = self.read_once(seq)
|
||||||
|
except Exception: # noqa: BLE001 — a transient read error must not kill the monitor
|
||||||
|
logger.warning("Measurement read failed; continuing", exc_info=True)
|
||||||
|
reading = None
|
||||||
|
if reading is not None:
|
||||||
|
self.writer.write(reading)
|
||||||
|
logger.debug(
|
||||||
|
"Published reading seq=%d T1=%.3f T2=%.3f", seq, reading.temp1, reading.temp2
|
||||||
|
)
|
||||||
|
seq += 1
|
||||||
|
stop.wait(self.period_s)
|
||||||
|
logger.info("Temperature monitor stopped after %d readings", seq)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt(value: object) -> Optional[float]:
|
||||||
|
return None if value is None else float(value)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""JSONL append/tail channel carrying per-sweep temperature readings.
|
||||||
|
|
||||||
|
The monitor process appends one JSON object per line; the checker process tails
|
||||||
|
the file from its end and parses each newly-appended line. A newline-delimited
|
||||||
|
file is used (rather than a socket) so the monitor and checker can start, stop,
|
||||||
|
and restart on independent lifecycles without a handshake — the checker simply
|
||||||
|
resumes tailing wherever the file currently ends.
|
||||||
|
|
||||||
|
A reader only ever consumes lines terminated by ``\\n``; a partially-written last
|
||||||
|
line is left buffered until its newline arrives, so a reading is never parsed
|
||||||
|
half-written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Optional, Union
|
||||||
|
|
||||||
|
_PathLike = Union[str, os.PathLike[str]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TemperatureReading:
|
||||||
|
"""One temperature/current snapshot published once per sweep.
|
||||||
|
|
||||||
|
``temp1``/``temp2`` are the internal laser temperatures (the values validated
|
||||||
|
against the setpoints); ``temp_ext1``/``temp_ext2`` are the external
|
||||||
|
thermistors, carried for diagnostics only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
seq: int
|
||||||
|
mono_ns: int
|
||||||
|
temp1: float
|
||||||
|
temp2: float
|
||||||
|
temp_ext1: Optional[float] = None
|
||||||
|
temp_ext2: Optional[float] = None
|
||||||
|
current1: Optional[float] = None
|
||||||
|
current2: Optional[float] = None
|
||||||
|
|
||||||
|
def to_json_line(self) -> str:
|
||||||
|
return json.dumps(asdict(self), separators=(",", ":"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json_line(cls, line: str) -> "TemperatureReading":
|
||||||
|
payload = json.loads(line)
|
||||||
|
return cls(
|
||||||
|
seq=int(payload["seq"]),
|
||||||
|
mono_ns=int(payload["mono_ns"]),
|
||||||
|
temp1=float(payload["temp1"]),
|
||||||
|
temp2=float(payload["temp2"]),
|
||||||
|
temp_ext1=_opt_float(payload.get("temp_ext1")),
|
||||||
|
temp_ext2=_opt_float(payload.get("temp_ext2")),
|
||||||
|
current1=_opt_float(payload.get("current1")),
|
||||||
|
current2=_opt_float(payload.get("current2")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_float(value: object) -> Optional[float]:
|
||||||
|
return None if value is None else float(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingWriter:
|
||||||
|
"""Appends :class:`TemperatureReading` objects to a JSONL file.
|
||||||
|
|
||||||
|
Each write is a single line flushed to the OS so a tailing reader sees it
|
||||||
|
promptly. Use as a context manager or call :meth:`close` explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: _PathLike) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Line-buffered append; each reading is one line.
|
||||||
|
self._fh = self.path.open("a", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
def write(self, reading: TemperatureReading) -> None:
|
||||||
|
self._fh.write(reading.to_json_line() + "\n")
|
||||||
|
self._fh.flush()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if not self._fh.closed:
|
||||||
|
self._fh.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "ReadingWriter":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingReader:
|
||||||
|
"""Tails a JSONL readings file, yielding complete lines as they appear.
|
||||||
|
|
||||||
|
``from_start=False`` (default) begins at the current end of file, so the
|
||||||
|
checker validates readings produced from the moment it starts. Partial
|
||||||
|
trailing lines are buffered until their newline arrives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: _PathLike, *, from_start: bool = False) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._buffer = ""
|
||||||
|
self._pos = 0
|
||||||
|
if not from_start and self.path.exists():
|
||||||
|
self._pos = self.path.stat().st_size
|
||||||
|
|
||||||
|
def poll(self) -> Iterator[TemperatureReading]:
|
||||||
|
"""Yield every complete reading appended since the last poll.
|
||||||
|
|
||||||
|
Malformed lines are skipped silently (a truncated/legacy line must not
|
||||||
|
crash a long-running checker); callers that care can validate seq gaps.
|
||||||
|
"""
|
||||||
|
if not self.path.exists():
|
||||||
|
return
|
||||||
|
with self.path.open("r", encoding="utf-8") as fh:
|
||||||
|
fh.seek(self._pos)
|
||||||
|
chunk = fh.read()
|
||||||
|
self._pos = fh.tell()
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
self._buffer += chunk
|
||||||
|
*complete, self._buffer = self._buffer.split("\n")
|
||||||
|
for line in complete:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
yield TemperatureReading.from_json_line(line)
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
continue
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Variation-session snapshot shared between the temperature monitor and checker.
|
||||||
|
|
||||||
|
When a current-variation task is started, the target static laser temperatures
|
||||||
|
(``static_temp1``/``static_temp2``) and the acceptable tolerance are frozen into a
|
||||||
|
small JSON file. The temperature checker reads this file to know what "correct"
|
||||||
|
means for the run, so it validates against the setpoints that were in effect *at
|
||||||
|
variation start* — independent of any later edits to the run config.
|
||||||
|
|
||||||
|
Only current variation of laser 1 (``CHANGE_CURRENT_LD1``) is supported by the
|
||||||
|
firmware today; the session still records both laser targets because both
|
||||||
|
temperatures are held static during that task and both are validated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
_PathLike = Union[str, os.PathLike[str]]
|
||||||
|
|
||||||
|
# Default IPC locations. Both are overridable via CLI/API so several runs can use
|
||||||
|
# distinct files. Kept in the system temp dir so no project state is polluted.
|
||||||
|
DEFAULT_SESSION_PATH = Path(tempfile.gettempdir()) / "laser_variation_session.json"
|
||||||
|
DEFAULT_READINGS_PATH = Path(tempfile.gettempdir()) / "laser_temp_readings.jsonl"
|
||||||
|
|
||||||
|
DEFAULT_TOLERANCE_C = 0.03
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaserVariationSession:
|
||||||
|
"""Target setpoints and tolerance frozen at variation start."""
|
||||||
|
|
||||||
|
variation_type: str
|
||||||
|
target_temp1: float
|
||||||
|
target_temp2: float
|
||||||
|
tolerance_c: float = DEFAULT_TOLERANCE_C
|
||||||
|
started_at_iso: str = ""
|
||||||
|
|
||||||
|
def save(self, path: _PathLike = DEFAULT_SESSION_PATH) -> Path:
|
||||||
|
"""Atomically write the session snapshot to ``path`` and return it.
|
||||||
|
|
||||||
|
Writes to a temp file in the same directory then renames, so a concurrent
|
||||||
|
checker never observes a half-written file.
|
||||||
|
"""
|
||||||
|
dest = Path(path)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.tmp")
|
||||||
|
tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: _PathLike = DEFAULT_SESSION_PATH) -> "LaserVariationSession":
|
||||||
|
"""Load a session snapshot from ``path``.
|
||||||
|
|
||||||
|
Raises FileNotFoundError if the file is absent and ValueError if it is not
|
||||||
|
a valid session object.
|
||||||
|
"""
|
||||||
|
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"Session file must be a JSON object: {path}")
|
||||||
|
try:
|
||||||
|
return cls(
|
||||||
|
variation_type=str(payload["variation_type"]),
|
||||||
|
target_temp1=float(payload["target_temp1"]),
|
||||||
|
target_temp2=float(payload["target_temp2"]),
|
||||||
|
tolerance_c=float(payload.get("tolerance_c", DEFAULT_TOLERANCE_C)),
|
||||||
|
started_at_iso=str(payload.get("started_at_iso", "")),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"Malformed session file {path}: {exc}") from exc
|
||||||
@@ -282,10 +282,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.range_comp_power,
|
gui.processing.gpr.range_comp_power,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
angle_comp_power=_optional_float(
|
object_min_frac=_optional_float(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"angle_comp_power",
|
"object_min_frac",
|
||||||
gui.processing.gpr.angle_comp_power,
|
gui.processing.gpr.object_min_frac,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
score_mode=_optional_string(
|
score_mode=_optional_string(
|
||||||
@@ -336,6 +336,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.draw_top_m_objects,
|
gui.processing.gpr.draw_top_m_objects,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
|
object_approach_min_frames=_optional_int(
|
||||||
|
gpr_object,
|
||||||
|
"object_approach_min_frames",
|
||||||
|
gui.processing.gpr.object_approach_min_frames,
|
||||||
|
"gui.processing.gpr",
|
||||||
|
),
|
||||||
start_freq_mhz=_optional_float(
|
start_freq_mhz=_optional_float(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"start_freq_mhz",
|
"start_freq_mhz",
|
||||||
@@ -378,12 +384,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.render_mode,
|
gui.processing.gpr.render_mode,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
min_visible_score=_optional_float(
|
|
||||||
gpr_object,
|
|
||||||
"min_visible_score",
|
|
||||||
gui.processing.gpr.min_visible_score,
|
|
||||||
"gui.processing.gpr",
|
|
||||||
),
|
|
||||||
visible_x_min_m=_optional_float(
|
visible_x_min_m=_optional_float(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"visible_x_min_m",
|
"visible_x_min_m",
|
||||||
@@ -473,14 +473,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
)
|
)
|
||||||
if gui.processing.gpr.range_comp_power < 0.0:
|
if gui.processing.gpr.range_comp_power < 0.0:
|
||||||
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
|
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
|
||||||
if gui.processing.gpr.angle_comp_power < 0.0:
|
if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0:
|
||||||
raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0")
|
raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]")
|
||||||
if gui.processing.gpr.min_visible_score < 0.0:
|
|
||||||
raise ValueError("gui.processing.gpr.min_visible_score must be >= 0")
|
|
||||||
if gui.processing.gpr.max_detected_objects_to_draw < 0:
|
if gui.processing.gpr.max_detected_objects_to_draw < 0:
|
||||||
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
|
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
|
||||||
if gui.processing.gpr.draw_top_m_objects < 0:
|
if gui.processing.gpr.draw_top_m_objects < 0:
|
||||||
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
|
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
|
||||||
|
if gui.processing.gpr.object_approach_min_frames < 1:
|
||||||
|
raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1")
|
||||||
if gui.processing.legacy_gpr.comp_power < 0.0:
|
if gui.processing.legacy_gpr.comp_power < 0.0:
|
||||||
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
|
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
|
||||||
if gui.processing.legacy_gpr.snr_thresh < 0.0:
|
if gui.processing.legacy_gpr.snr_thresh < 0.0:
|
||||||
@@ -593,7 +593,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"min_depth_m": gui.processing.gpr.min_depth_m,
|
"min_depth_m": gui.processing.gpr.min_depth_m,
|
||||||
"max_depth_m": gui.processing.gpr.max_depth_m,
|
"max_depth_m": gui.processing.gpr.max_depth_m,
|
||||||
"range_comp_power": gui.processing.gpr.range_comp_power,
|
"range_comp_power": gui.processing.gpr.range_comp_power,
|
||||||
"angle_comp_power": gui.processing.gpr.angle_comp_power,
|
"object_min_frac": gui.processing.gpr.object_min_frac,
|
||||||
"score_mode": gui.processing.gpr.score_mode,
|
"score_mode": gui.processing.gpr.score_mode,
|
||||||
"motion_mode": gui.processing.gpr.motion_mode,
|
"motion_mode": gui.processing.gpr.motion_mode,
|
||||||
"look_angle_deg": gui.processing.gpr.look_angle_deg,
|
"look_angle_deg": gui.processing.gpr.look_angle_deg,
|
||||||
@@ -602,6 +602,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
|
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
|
||||||
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
|
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
|
||||||
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
|
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
|
||||||
|
"object_approach_min_frames": gui.processing.gpr.object_approach_min_frames,
|
||||||
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
||||||
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
|
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
|
||||||
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
||||||
@@ -609,7 +610,6 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
|
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
|
||||||
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
|
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
|
||||||
"render_mode": gui.processing.gpr.render_mode,
|
"render_mode": gui.processing.gpr.render_mode,
|
||||||
"min_visible_score": gui.processing.gpr.min_visible_score,
|
|
||||||
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
|
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
|
||||||
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
|
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
|
||||||
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
|
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ class GuiGprStateModel:
|
|||||||
min_depth_m: float = 2.0
|
min_depth_m: float = 2.0
|
||||||
max_depth_m: float = 14.0
|
max_depth_m: float = 14.0
|
||||||
range_comp_power: float = 0.1
|
range_comp_power: float = 0.1
|
||||||
angle_comp_power: float = 0.0
|
# BP object-detection stop level, fraction of the global peak (Horns_motion_3libre.py
|
||||||
|
# BP_OBJECT_MIN_FRAC). Angle compensation and permittivity are fixed for coherent BP
|
||||||
|
# (Python 0.3 block), so they are not exposed here.
|
||||||
|
object_min_frac: float = 0.7
|
||||||
score_mode: str = "combined"
|
score_mode: str = "combined"
|
||||||
motion_mode: str = "int_minus"
|
motion_mode: str = "int_minus"
|
||||||
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
|
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
|
||||||
@@ -75,6 +78,9 @@ class GuiGprStateModel:
|
|||||||
ignore_socket_speed_enabled: bool = False
|
ignore_socket_speed_enabled: bool = False
|
||||||
max_detected_objects_to_draw: int = 5
|
max_detected_objects_to_draw: int = 5
|
||||||
draw_top_m_objects: int = 2
|
draw_top_m_objects: int = 2
|
||||||
|
# Cross-frame approach filter: show an object only after it persists as a
|
||||||
|
# motion-consistent track this many consecutive frames (<= 1 disables it).
|
||||||
|
object_approach_min_frames: int = 3
|
||||||
start_freq_mhz: float = 3000.0
|
start_freq_mhz: float = 3000.0
|
||||||
stop_freq_mhz: float = 6000.0
|
stop_freq_mhz: float = 6000.0
|
||||||
background_subtract_enabled: bool = True
|
background_subtract_enabled: bool = True
|
||||||
@@ -82,7 +88,6 @@ class GuiGprStateModel:
|
|||||||
remove_sidelobe_objects_enabled: bool = True
|
remove_sidelobe_objects_enabled: bool = True
|
||||||
imaging_plane_y_m: float = 0.0
|
imaging_plane_y_m: float = 0.0
|
||||||
render_mode: str = "heatmap"
|
render_mode: str = "heatmap"
|
||||||
min_visible_score: float = 0.0
|
|
||||||
visible_x_min_m: float = -2.0
|
visible_x_min_m: float = -2.0
|
||||||
visible_x_max_m: float = 2.0
|
visible_x_max_m: float = 2.0
|
||||||
visible_z_min_m: float = 0.0
|
visible_z_min_m: float = 0.0
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
model.radar.laser_control.variation.delay_time = _read_int(
|
model.radar.laser_control.variation.delay_time = _read_int(
|
||||||
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
|
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
|
||||||
)
|
)
|
||||||
|
model.radar.laser_control.variation.temp_tolerance_c = _read_float(
|
||||||
|
laser_variation_payload,
|
||||||
|
"temp_tolerance_c",
|
||||||
|
model.radar.laser_control.variation.temp_tolerance_c,
|
||||||
|
)
|
||||||
|
|
||||||
load_switch_payload(port1_payload, model.output_switch)
|
load_switch_payload(port1_payload, model.output_switch)
|
||||||
load_switch_payload(port2_payload, model.input_switch)
|
load_switch_payload(port2_payload, model.input_switch)
|
||||||
@@ -552,6 +557,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
|||||||
"step": model.radar.laser_control.variation.step,
|
"step": model.radar.laser_control.variation.step,
|
||||||
"time_step": model.radar.laser_control.variation.time_step,
|
"time_step": model.radar.laser_control.variation.time_step,
|
||||||
"delay_time": model.radar.laser_control.variation.delay_time,
|
"delay_time": model.radar.laser_control.variation.delay_time,
|
||||||
|
"temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"sweep": sweep_payload,
|
"sweep": sweep_payload,
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ class LaserVariationModeModel:
|
|||||||
step: float = 0.1
|
step: float = 0.1
|
||||||
time_step: int = 20
|
time_step: int = 20
|
||||||
delay_time: int = 3
|
delay_time: int = 3
|
||||||
|
# Max allowed |measured - target| laser temperature before the temperature
|
||||||
|
# checker warns, °C. Applied independently to both lasers (temp1/temp2).
|
||||||
|
temp_tolerance_c: float = 0.03
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class ProcessingLiveConfig:
|
|||||||
gpr_min_depth_m: float = 2.0
|
gpr_min_depth_m: float = 2.0
|
||||||
gpr_max_depth_m: float = 14.0
|
gpr_max_depth_m: float = 14.0
|
||||||
gpr_range_comp_power: float = 0.1
|
gpr_range_comp_power: float = 0.1
|
||||||
gpr_angle_comp_power: float = 0.0
|
|
||||||
gpr_comp_power: float = 0.2
|
gpr_comp_power: float = 0.2
|
||||||
|
gpr_object_min_frac: float = 0.7
|
||||||
gpr_score_mode: str = "combined"
|
gpr_score_mode: str = "combined"
|
||||||
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
||||||
# correction) or "int_focus" (focusing residual only). Mirrors Python
|
# correction) or "int_focus" (focusing residual only). Mirrors Python
|
||||||
@@ -41,6 +41,7 @@ class ProcessingLiveConfig:
|
|||||||
gpr_motion_mode: str = "int_minus"
|
gpr_motion_mode: str = "int_minus"
|
||||||
gpr_max_detected_objects_to_draw: int = 5
|
gpr_max_detected_objects_to_draw: int = 5
|
||||||
gpr_draw_top_m_objects: int = 2
|
gpr_draw_top_m_objects: int = 2
|
||||||
|
gpr_object_approach_min_frames: int = 3
|
||||||
gpr_speed_m_s: float = 0.0
|
gpr_speed_m_s: float = 0.0
|
||||||
gpr_look_angle_deg: float = 0.0
|
gpr_look_angle_deg: float = 0.0
|
||||||
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
|
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
|
||||||
@@ -61,8 +62,9 @@ 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.
|
# Locator filter parameter consumed by the C++ TCP locator server. Coherent BP
|
||||||
gpr_min_visible_score: float = 0.0
|
# objects are already finalized in the processor (no score threshold); only legacy
|
||||||
|
# GPR still thresholds, on a pair count.
|
||||||
legacy_gpr_min_visible_pair_count: float = 0.0
|
legacy_gpr_min_visible_pair_count: float = 0.0
|
||||||
# Visible X/Z window (metres). The locator and the desktop plot both clip
|
# Visible X/Z window (metres). The locator and the desktop plot both clip
|
||||||
# detected objects to this window, so the socket broadcasts only what is shown.
|
# detected objects to this window, so the socket broadcasts only what is shown.
|
||||||
@@ -109,12 +111,13 @@ class ProcessingLiveConfig:
|
|||||||
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
||||||
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
||||||
"gpr_range_comp_power": float(self.gpr_range_comp_power),
|
"gpr_range_comp_power": float(self.gpr_range_comp_power),
|
||||||
"gpr_angle_comp_power": float(self.gpr_angle_comp_power),
|
|
||||||
"gpr_comp_power": float(self.gpr_comp_power),
|
"gpr_comp_power": float(self.gpr_comp_power),
|
||||||
|
"gpr_object_min_frac": float(self.gpr_object_min_frac),
|
||||||
"gpr_score_mode": str(self.gpr_score_mode),
|
"gpr_score_mode": str(self.gpr_score_mode),
|
||||||
"gpr_motion_mode": str(self.gpr_motion_mode),
|
"gpr_motion_mode": str(self.gpr_motion_mode),
|
||||||
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
|
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
|
||||||
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
|
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
|
||||||
|
"gpr_object_approach_min_frames": int(self.gpr_object_approach_min_frames),
|
||||||
"gpr_speed_m_s": float(self.gpr_speed_m_s),
|
"gpr_speed_m_s": float(self.gpr_speed_m_s),
|
||||||
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
|
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
|
||||||
"gpr_direction_sign": float(self.gpr_direction_sign),
|
"gpr_direction_sign": float(self.gpr_direction_sign),
|
||||||
@@ -128,7 +131,6 @@ 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),
|
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
||||||
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
||||||
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ _OPEN_RETRY_LOG_EVERY = 30
|
|||||||
def _open_radar_with_retry(
|
def _open_radar_with_retry(
|
||||||
config: RunConfigModel,
|
config: RunConfigModel,
|
||||||
radar: KamilAdcService,
|
radar: KamilAdcService,
|
||||||
input_switch: SwitchService,
|
input_switch: SwitchService | None,
|
||||||
output_switch: SwitchService,
|
output_switch: SwitchService | None,
|
||||||
stop_requested: threading.Event,
|
stop_requested: threading.Event,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Open+configure the radar and both switches, retrying forever until stop.
|
"""Open+configure the radar and both switches, retrying forever until stop.
|
||||||
@@ -48,12 +48,17 @@ def _open_radar_with_retry(
|
|||||||
relaunched collector starts clean. Returns ``True`` once everything is open, or
|
relaunched collector starts clean. Returns ``True`` once everything is open, or
|
||||||
``False`` if a stop was requested before the device became available. Backoff is
|
``False`` if a stop was requested before the device became available. Backoff is
|
||||||
capped and every wait is interruptible by SIGTERM.
|
capped and every wait is interruptible by SIGTERM.
|
||||||
|
|
||||||
|
``input_switch``/``output_switch`` are ``None`` in switch-aware mode, where the
|
||||||
|
collector owns the GPIO lines and the producer must not open them.
|
||||||
"""
|
"""
|
||||||
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
|
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
|
||||||
# while still "open", so a mid-run reconnect must close them to force a fresh
|
# while still "open", so a mid-run reconnect must close them to force a fresh
|
||||||
# collector relaunch and TTY re-attach.
|
# collector relaunch and TTY re-attach.
|
||||||
|
if input_switch is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
input_switch.close()
|
input_switch.close()
|
||||||
|
if output_switch is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
output_switch.close()
|
output_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
@@ -65,13 +70,17 @@ def _open_radar_with_retry(
|
|||||||
try:
|
try:
|
||||||
radar.open(stop_event=stop_requested)
|
radar.open(stop_event=stop_requested)
|
||||||
radar.configure(config.radar.sweep)
|
radar.configure(config.radar.sweep)
|
||||||
|
if output_switch is not None:
|
||||||
output_switch.open()
|
output_switch.open()
|
||||||
|
if input_switch is not None:
|
||||||
input_switch.open()
|
input_switch.open()
|
||||||
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
|
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
|
||||||
# Drop any partial open (collector process, TTY reader, switches)
|
# Drop any partial open (collector process, TTY reader, switches)
|
||||||
# before the next attempt so the relaunch starts from a clean state.
|
# before the next attempt so the relaunch starts from a clean state.
|
||||||
|
if input_switch is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
input_switch.close()
|
input_switch.close()
|
||||||
|
if output_switch is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
output_switch.close()
|
output_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
@@ -136,9 +145,26 @@ def main() -> int:
|
|||||||
"Opened SHM ring writers: raw=%s, raw_tap=%s",
|
"Opened SHM ring writers: raw=%s, raw_tap=%s",
|
||||||
config.rings.raw.name, config.rings.raw_tap.name,
|
config.rings.raw.name, config.rings.raw_tap.name,
|
||||||
)
|
)
|
||||||
radar = KamilAdcService(config)
|
# Switch-aware mode: with native switches the collector drives the RF switches
|
||||||
input_switch = SwitchService.from_model(config.input_switch)
|
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
|
||||||
output_switch = SwitchService.from_model(config.output_switch)
|
# — no sweep lost at a switch boundary. The producer then only reads tagged
|
||||||
|
# sweeps and must not touch the GPIO lines the collector owns. With mock switches
|
||||||
|
# (dev/tests) we keep the Python-driven path, which is fine where speed and the
|
||||||
|
# in-gap timing do not matter.
|
||||||
|
collector_driven = (
|
||||||
|
config.input_switch.driver_mode == "native"
|
||||||
|
and config.output_switch.driver_mode == "native"
|
||||||
|
)
|
||||||
|
radar = KamilAdcService(
|
||||||
|
config,
|
||||||
|
switch_config_path=str(args.config) if collector_driven else None,
|
||||||
|
)
|
||||||
|
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
|
||||||
|
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
|
||||||
|
logger.info(
|
||||||
|
"Kamil ADC switch control: %s",
|
||||||
|
"collector-driven (in-gap, lossless)" if collector_driven else "producer-driven",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
||||||
@@ -155,11 +181,15 @@ def main() -> int:
|
|||||||
for combo in config.combos:
|
for combo in config.combos:
|
||||||
if stop_requested.is_set():
|
if stop_requested.is_set():
|
||||||
break
|
break
|
||||||
|
if collector_driven:
|
||||||
|
# The collector already switched and tagged the sweep; just
|
||||||
|
# read the clean capture for this combination.
|
||||||
|
sweep = radar.acquire(combo=(combo.input, combo.output))
|
||||||
|
else:
|
||||||
output_switch.switch_to(combo.output)
|
output_switch.switch_to(combo.output)
|
||||||
input_switch.switch_to(combo.input)
|
input_switch.switch_to(combo.input)
|
||||||
if config.runtime.settling_ms > 0:
|
if config.runtime.settling_ms > 0:
|
||||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||||
|
|
||||||
sweep = radar.acquire()
|
sweep = radar.acquire()
|
||||||
traces.append(
|
traces.append(
|
||||||
TraceData(
|
TraceData(
|
||||||
@@ -222,8 +252,10 @@ def main() -> int:
|
|||||||
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
||||||
collection_id += 1
|
collection_id += 1
|
||||||
finally:
|
finally:
|
||||||
|
if output_switch is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
output_switch.close()
|
output_switch.close()
|
||||||
|
if input_switch is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
input_switch.close()
|
input_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Standalone laser temperature checker process.
|
||||||
|
|
||||||
|
Reads the target setpoints frozen at variation start and tails the JSONL readings
|
||||||
|
channel produced by the monitor. For every reading it validates both lasers and
|
||||||
|
prints a console warning whenever a measured temperature drifts from its target by
|
||||||
|
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
|
||||||
|
fully independent of the monitor and can be started/stopped at any time.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
python -m python_app.scripts.laser_temp_checker
|
||||||
|
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
DEFAULT_READINGS_PATH,
|
||||||
|
DEFAULT_SESSION_PATH,
|
||||||
|
LaserTemperatureChecker,
|
||||||
|
LaserVariationSession,
|
||||||
|
ReadingReader,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("laser_temp_checker")
|
||||||
|
|
||||||
|
_POLL_INTERVAL_S = 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
|
||||||
|
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
||||||
|
help="Session snapshot with target setpoints + tolerance")
|
||||||
|
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
||||||
|
help="JSONL readings channel to tail")
|
||||||
|
parser.add_argument("--tolerance", type=float, default=None,
|
||||||
|
help="Override tolerance in °C (default: from session)")
|
||||||
|
parser.add_argument("--reminder-every", type=int, default=0,
|
||||||
|
help="Repeat a warning every N readings while off target (0=off)")
|
||||||
|
parser.add_argument("--from-start", action="store_true",
|
||||||
|
help="Validate the whole readings file, not just new lines")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
session = LaserVariationSession.load(args.session)
|
||||||
|
if args.tolerance is not None:
|
||||||
|
session.tolerance_c = args.tolerance
|
||||||
|
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
|
||||||
|
logger.info(
|
||||||
|
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
|
||||||
|
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
reader = ReadingReader(args.readings, from_start=args.from_start)
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
def request_stop(_signum: int, _frame: object) -> None:
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, request_stop)
|
||||||
|
signal.signal(signal.SIGTERM, request_stop)
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
for reading in reader.poll():
|
||||||
|
checker.process(reading)
|
||||||
|
stop_event.wait(_POLL_INTERVAL_S)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Standalone laser temperature monitor process.
|
||||||
|
|
||||||
|
Owns the laser serial port, (optionally) starts a current-variation task, then
|
||||||
|
polls the board once per sweep and appends each reading to a JSONL channel that
|
||||||
|
the temperature checker tails. Runs until SIGINT/SIGTERM.
|
||||||
|
|
||||||
|
Examples::
|
||||||
|
|
||||||
|
# Start LD1 current variation from a run config, then monitor:
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config run_config.json --start
|
||||||
|
|
||||||
|
# Monitor a variation that is already running:
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.controller import (
|
||||||
|
DEVICE_MAIN_MESSAGE_ID,
|
||||||
|
LaserController,
|
||||||
|
)
|
||||||
|
from python_app.hardware_full.laser_control.exceptions import PortBusyError
|
||||||
|
from python_app.hardware_full.laser_control.models import VariationType
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
DEFAULT_READINGS_PATH,
|
||||||
|
DEFAULT_SESSION_PATH,
|
||||||
|
LaserTemperatureMonitor,
|
||||||
|
LaserVariationSession,
|
||||||
|
ReadingWriter,
|
||||||
|
resolve_period_s,
|
||||||
|
)
|
||||||
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
|
|
||||||
|
logger = logging.getLogger("laser_temp_monitor")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_variation(controller: LaserController, variation) -> None:
|
||||||
|
"""Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot."""
|
||||||
|
controller.reset()
|
||||||
|
controller.set_manual_mode(
|
||||||
|
temp1=variation.static_temp1,
|
||||||
|
temp2=variation.static_temp2,
|
||||||
|
current1=variation.static_current1,
|
||||||
|
current2=variation.static_current2,
|
||||||
|
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||||
|
)
|
||||||
|
controller.start_variation(
|
||||||
|
variation_type=VariationType[variation.variation_type],
|
||||||
|
params={
|
||||||
|
"static_temp1": variation.static_temp1,
|
||||||
|
"static_temp2": variation.static_temp2,
|
||||||
|
"static_current1": variation.static_current1,
|
||||||
|
"static_current2": variation.static_current2,
|
||||||
|
"min_value": variation.min_value,
|
||||||
|
"max_value": variation.max_value,
|
||||||
|
"step": variation.step,
|
||||||
|
"time_step": variation.time_step,
|
||||||
|
"delay_time": variation.delay_time,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep")
|
||||||
|
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
|
||||||
|
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
||||||
|
help="JSONL readings channel to append to")
|
||||||
|
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
||||||
|
help="Session snapshot path (written with --start)")
|
||||||
|
parser.add_argument("--strategy", default="computed",
|
||||||
|
help="'computed' (per sweep) or 'interval:<ms>'")
|
||||||
|
parser.add_argument("--start", action="store_true",
|
||||||
|
help="Send CHANGE_CURRENT_LD1 before monitoring")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
config = RunConfigModel.load_from_path(args.config)
|
||||||
|
laser = config.radar.laser_control
|
||||||
|
variation = laser.variation
|
||||||
|
if variation.variation_type != "CHANGE_CURRENT_LD1":
|
||||||
|
logger.warning(
|
||||||
|
"Only CHANGE_CURRENT_LD1 is supported by firmware; got %s",
|
||||||
|
variation.variation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
period_s = resolve_period_s(
|
||||||
|
args.strategy,
|
||||||
|
min_value=variation.min_value,
|
||||||
|
max_value=variation.max_value,
|
||||||
|
step=variation.step,
|
||||||
|
time_step_us=variation.time_step,
|
||||||
|
delay_time_ms=variation.delay_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
def request_stop(_signum: int, _frame: object) -> None:
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, request_stop)
|
||||||
|
signal.signal(signal.SIGTERM, request_stop)
|
||||||
|
|
||||||
|
controller = LaserController(
|
||||||
|
port=laser.port or None,
|
||||||
|
pi_coeff1_p=laser.pi_coeff1_p,
|
||||||
|
pi_coeff1_i=laser.pi_coeff1_i,
|
||||||
|
pi_coeff2_p=laser.pi_coeff2_p,
|
||||||
|
pi_coeff2_i=laser.pi_coeff2_i,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
controller.connect()
|
||||||
|
except PortBusyError as exc:
|
||||||
|
# Expected, benign conflict: the manual-control UI (or another monitor)
|
||||||
|
# already owns the port. Exit cleanly with guidance, not a traceback.
|
||||||
|
logger.error("%s", exc)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
if args.start:
|
||||||
|
_start_variation(controller, variation)
|
||||||
|
LaserVariationSession(
|
||||||
|
variation_type=variation.variation_type,
|
||||||
|
target_temp1=variation.static_temp1,
|
||||||
|
target_temp2=variation.static_temp2,
|
||||||
|
tolerance_c=variation.temp_tolerance_c,
|
||||||
|
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
||||||
|
).save(args.session)
|
||||||
|
logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session)
|
||||||
|
|
||||||
|
with ReadingWriter(args.readings) as writer:
|
||||||
|
monitor = LaserTemperatureMonitor(
|
||||||
|
controller=controller, writer=writer, period_s=period_s
|
||||||
|
)
|
||||||
|
logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s)
|
||||||
|
monitor.run(stop_event)
|
||||||
|
finally:
|
||||||
|
controller.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -8,6 +8,7 @@ import unittest
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc.protocol import (
|
from python_app.hardware_full.kamil_adc.protocol import (
|
||||||
|
COMBO_MARKER,
|
||||||
MAIN_MARKER,
|
MAIN_MARKER,
|
||||||
REFERENCE_MARKER,
|
REFERENCE_MARKER,
|
||||||
KamilAdcStreamParser,
|
KamilAdcStreamParser,
|
||||||
@@ -18,6 +19,10 @@ def _boundary() -> bytes:
|
|||||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
||||||
|
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
||||||
|
|
||||||
|
|
||||||
def _main(step: int, real: int, imag: int) -> bytes:
|
def _main(step: int, real: int, imag: int) -> bytes:
|
||||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||||
|
|
||||||
@@ -140,6 +145,40 @@ class KamilAdcStreamParserTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(sweeps), 1)
|
self.assertEqual(len(sweeps), 1)
|
||||||
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
||||||
|
|
||||||
|
def test_untagged_sweep_has_no_combo(self) -> None:
|
||||||
|
parser = KamilAdcStreamParser()
|
||||||
|
(sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
||||||
|
self.assertIsNone(sweep.combo)
|
||||||
|
self.assertFalse(sweep.dirty)
|
||||||
|
|
||||||
|
def test_combo_tag_labels_following_sweep(self) -> None:
|
||||||
|
parser = KamilAdcStreamParser()
|
||||||
|
stream = (
|
||||||
|
_boundary() + _combo(1, 2)
|
||||||
|
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
||||||
|
+ _boundary() + _combo(3, 0, dirty=1)
|
||||||
|
+ _main(1, 20, 0) + _reference(1, 200, 0)
|
||||||
|
+ _boundary()
|
||||||
|
)
|
||||||
|
first, second = parser.feed(stream)
|
||||||
|
self.assertEqual(first.combo, (1, 2))
|
||||||
|
self.assertFalse(first.dirty)
|
||||||
|
self.assertEqual(second.combo, (3, 0))
|
||||||
|
self.assertTrue(second.dirty)
|
||||||
|
|
||||||
|
def test_combo_not_carried_into_untagged_sweep(self) -> None:
|
||||||
|
parser = KamilAdcStreamParser()
|
||||||
|
stream = (
|
||||||
|
_boundary() + _combo(1, 1)
|
||||||
|
+ _main(1, 1, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary() # next sweep has no combo frame
|
||||||
|
+ _main(2, 2, 0) + _reference(2, 2, 0)
|
||||||
|
+ _boundary()
|
||||||
|
)
|
||||||
|
first, second = parser.feed(stream)
|
||||||
|
self.assertEqual(first.combo, (1, 1))
|
||||||
|
self.assertIsNone(second.combo)
|
||||||
|
|
||||||
def test_dtypes(self) -> None:
|
def test_dtypes(self) -> None:
|
||||||
parser = KamilAdcStreamParser()
|
parser = KamilAdcStreamParser()
|
||||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import unittest
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
||||||
from python_app.hardware_full.kamil_adc.protocol import MAIN_MARKER, REFERENCE_MARKER
|
from python_app.hardware_full.kamil_adc.protocol import (
|
||||||
|
COMBO_MARKER,
|
||||||
|
MAIN_MARKER,
|
||||||
|
REFERENCE_MARKER,
|
||||||
|
)
|
||||||
from python_app.models.run_config_model import RunConfigModel
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@ def _reference(step: int, real: int, imag: int) -> bytes:
|
|||||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||||
|
|
||||||
|
|
||||||
|
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
||||||
|
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||||
"""End-to-end tests over a PTY exercising the background reader thread."""
|
"""End-to-end tests over a PTY exercising the background reader thread."""
|
||||||
|
|
||||||
@@ -127,6 +135,41 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
self._close(master_fd, slave_fd, reader)
|
self._close(master_fd, slave_fd, reader)
|
||||||
|
|
||||||
|
def test_read_sweep_for_demuxes_by_combo(self) -> None:
|
||||||
|
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||||
|
try:
|
||||||
|
os.write(
|
||||||
|
master_fd,
|
||||||
|
_boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary(),
|
||||||
|
)
|
||||||
|
# Each combination is served from its own slot, regardless of order.
|
||||||
|
second = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
||||||
|
self.assertEqual(second.main.real.tolist(), [22])
|
||||||
|
self.assertEqual(second.combo, (0, 1))
|
||||||
|
first = reader.read_sweep_for((0, 0), timeout_s=1.0)
|
||||||
|
self.assertEqual(first.main.real.tolist(), [11])
|
||||||
|
finally:
|
||||||
|
self._close(master_fd, slave_fd, reader)
|
||||||
|
|
||||||
|
def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None:
|
||||||
|
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||||
|
try:
|
||||||
|
os.write(
|
||||||
|
master_fd,
|
||||||
|
# A dirty combo (0,1) sweep, then its clean re-take of the same combo.
|
||||||
|
_boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary(),
|
||||||
|
)
|
||||||
|
sweep = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
||||||
|
# The dirty sweep (99) is dropped; only the clean re-take (42) is served.
|
||||||
|
self.assertEqual(sweep.main.real.tolist(), [42])
|
||||||
|
self.assertFalse(sweep.dirty)
|
||||||
|
finally:
|
||||||
|
self._close(master_fd, slave_fd, reader)
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcConfigTest(unittest.TestCase):
|
class KamilAdcConfigTest(unittest.TestCase):
|
||||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
||||||
@@ -205,6 +248,61 @@ class KamilAdcConfigTest(unittest.TestCase):
|
|||||||
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
||||||
service.close() # must not raise
|
service.close() # must not raise
|
||||||
|
|
||||||
|
def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None:
|
||||||
|
"""After a switch change, drain must skip the configured number of freshly
|
||||||
|
published sweeps before returning, so the next capture is post-switch."""
|
||||||
|
import types
|
||||||
|
|
||||||
|
from python_app.hardware_full.kamil_adc import service as service_module
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
config = RunConfigModel.from_dict(
|
||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "kamil_adc",
|
||||||
|
"driver_mode": "native",
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": tmp_dir,
|
||||||
|
"executable_path": "/bin/sh",
|
||||||
|
"tty_path": "/tmp/ttyADC_test",
|
||||||
|
"sweep_timeout_s": 5.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = KamilAdcService(config)
|
||||||
|
service._reader = types.SimpleNamespace(published_count=10) # type: ignore[assignment]
|
||||||
|
service._process = types.SimpleNamespace(poll=lambda: None) # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Each poll-sleep advances the published count, as the reader thread would.
|
||||||
|
def _advance(_seconds: float) -> None:
|
||||||
|
service._reader.published_count += 1
|
||||||
|
|
||||||
|
with mock.patch.object(service_module.time, "sleep", _advance):
|
||||||
|
service.drain_after_switch(sweeps=3)
|
||||||
|
|
||||||
|
# Started at 10, must have waited for at least 3 more sweeps.
|
||||||
|
self.assertGreaterEqual(service._reader.published_count, 13)
|
||||||
|
|
||||||
|
def test_drain_after_switch_is_noop_when_not_open(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
config = RunConfigModel.from_dict(
|
||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "kamil_adc",
|
||||||
|
"driver_mode": "native",
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": tmp_dir,
|
||||||
|
"executable_path": "/bin/sh",
|
||||||
|
"tty_path": "/tmp/ttyADC_test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
KamilAdcService(config).drain_after_switch() # no reader → must not raise
|
||||||
|
|
||||||
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
config_path = Path(tmp_dir) / "run_config.json"
|
config_path = Path(tmp_dir) / "run_config.json"
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for the laser current-variation temperature monitoring package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
LaserTemperatureChecker,
|
||||||
|
LaserTemperatureMonitor,
|
||||||
|
LaserVariationSession,
|
||||||
|
ReadingReader,
|
||||||
|
ReadingWriter,
|
||||||
|
TemperatureReading,
|
||||||
|
compute_sweep_period_s,
|
||||||
|
resolve_period_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeMeasurements:
|
||||||
|
temp1: float
|
||||||
|
temp2: float
|
||||||
|
temp_ext1: Optional[float] = None
|
||||||
|
temp_ext2: Optional[float] = None
|
||||||
|
current1: Optional[float] = None
|
||||||
|
current2: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeController:
|
||||||
|
"""Returns a queued sequence of measurements, then None."""
|
||||||
|
|
||||||
|
def __init__(self, measurements: List[Optional[_FakeMeasurements]]) -> None:
|
||||||
|
self._queue = list(measurements)
|
||||||
|
|
||||||
|
def get_measurements(self) -> Optional[_FakeMeasurements]:
|
||||||
|
return self._queue.pop(0) if self._queue else None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRoundTripTest(unittest.TestCase):
|
||||||
|
def test_save_then_load_preserves_targets_and_tolerance(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "session.json"
|
||||||
|
LaserVariationSession(
|
||||||
|
variation_type="CHANGE_CURRENT_LD1",
|
||||||
|
target_temp1=28.0,
|
||||||
|
target_temp2=28.9,
|
||||||
|
tolerance_c=0.03,
|
||||||
|
started_at_iso="2026-07-27T12:00:00",
|
||||||
|
).save(path)
|
||||||
|
|
||||||
|
loaded = LaserVariationSession.load(path)
|
||||||
|
|
||||||
|
self.assertEqual(loaded.variation_type, "CHANGE_CURRENT_LD1")
|
||||||
|
self.assertAlmostEqual(loaded.target_temp1, 28.0)
|
||||||
|
self.assertAlmostEqual(loaded.target_temp2, 28.9)
|
||||||
|
self.assertAlmostEqual(loaded.tolerance_c, 0.03)
|
||||||
|
|
||||||
|
def test_load_missing_file_raises(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
LaserVariationSession.load(Path(tmp) / "absent.json")
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingsChannelTest(unittest.TestCase):
|
||||||
|
def _reading(self, seq: int, t1: float = 25.0, t2: float = 25.0) -> TemperatureReading:
|
||||||
|
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
|
||||||
|
|
||||||
|
def test_reader_tails_appended_lines_in_order(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "readings.jsonl"
|
||||||
|
reader = ReadingReader(path) # start at (nonexistent) end
|
||||||
|
with ReadingWriter(path) as writer:
|
||||||
|
writer.write(self._reading(0, 25.0))
|
||||||
|
writer.write(self._reading(1, 26.0))
|
||||||
|
first = list(reader.poll())
|
||||||
|
writer.write(self._reading(2, 27.0))
|
||||||
|
second = list(reader.poll())
|
||||||
|
|
||||||
|
self.assertEqual([r.seq for r in first], [0, 1])
|
||||||
|
self.assertEqual([r.seq for r in second], [2])
|
||||||
|
self.assertAlmostEqual(first[1].temp1, 26.0)
|
||||||
|
|
||||||
|
def test_partial_trailing_line_is_buffered_until_newline(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "readings.jsonl"
|
||||||
|
path.write_text('{"seq":0,"mono_ns":0,"temp1":25.0,"temp2":25.0}\n{"seq":1,"mono',
|
||||||
|
encoding="utf-8")
|
||||||
|
reader = ReadingReader(path, from_start=True)
|
||||||
|
first = list(reader.poll())
|
||||||
|
# Complete the truncated line.
|
||||||
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write('_ns":1,"temp1":26.0,"temp2":26.0}\n')
|
||||||
|
second = list(reader.poll())
|
||||||
|
|
||||||
|
self.assertEqual([r.seq for r in first], [0])
|
||||||
|
self.assertEqual([r.seq for r in second], [1])
|
||||||
|
|
||||||
|
|
||||||
|
class SweepPeriodTest(unittest.TestCase):
|
||||||
|
def test_compute_sweep_period_matches_formula(self) -> None:
|
||||||
|
# (35-33)/0.05 = 40 -> 41 points; per point = 10ms + 50us = 0.01005s.
|
||||||
|
period = compute_sweep_period_s(33.0, 35.0, 0.05, time_step_us=50, delay_time_ms=10)
|
||||||
|
self.assertAlmostEqual(period, 41 * 0.01005, places=6)
|
||||||
|
|
||||||
|
def test_resolve_interval_strategy(self) -> None:
|
||||||
|
period = resolve_period_s(
|
||||||
|
"interval:250", min_value=33.0, max_value=35.0, step=0.05,
|
||||||
|
time_step_us=50, delay_time_ms=10,
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(period, 0.25)
|
||||||
|
|
||||||
|
def test_resolve_rejects_unknown_strategy(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
resolve_period_s("bogus", min_value=0, max_value=1, step=0.1,
|
||||||
|
time_step_us=50, delay_time_ms=10)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorTest(unittest.TestCase):
|
||||||
|
def test_read_once_maps_measurement_fields(self) -> None:
|
||||||
|
controller = _FakeController([_FakeMeasurements(
|
||||||
|
temp1=28.01, temp2=28.9, temp_ext1=22.0, temp_ext2=23.0,
|
||||||
|
current1=33.0, current2=35.0,
|
||||||
|
)])
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with ReadingWriter(Path(tmp) / "r.jsonl") as writer:
|
||||||
|
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||||
|
reading = monitor.read_once(7)
|
||||||
|
|
||||||
|
assert reading is not None
|
||||||
|
self.assertEqual(reading.seq, 7)
|
||||||
|
self.assertAlmostEqual(reading.temp1, 28.01)
|
||||||
|
self.assertAlmostEqual(reading.temp_ext1, 22.0)
|
||||||
|
self.assertAlmostEqual(reading.current2, 35.0)
|
||||||
|
|
||||||
|
def test_run_publishes_until_stopped(self) -> None:
|
||||||
|
controller = _FakeController([
|
||||||
|
_FakeMeasurements(28.0, 28.9),
|
||||||
|
_FakeMeasurements(28.0, 28.9),
|
||||||
|
])
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
class _OneShotWriter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.written: List[TemperatureReading] = []
|
||||||
|
|
||||||
|
def write(self, reading: TemperatureReading) -> None:
|
||||||
|
self.written.append(reading)
|
||||||
|
stop.set() # stop after the first publish
|
||||||
|
|
||||||
|
writer = _OneShotWriter()
|
||||||
|
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||||
|
monitor.run(stop)
|
||||||
|
|
||||||
|
self.assertEqual(len(writer.written), 1)
|
||||||
|
self.assertEqual(writer.written[0].seq, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckerTest(unittest.TestCase):
|
||||||
|
def _checker(self, **kwargs: object) -> LaserTemperatureChecker:
|
||||||
|
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
|
||||||
|
tolerance_c=0.03, **kwargs)
|
||||||
|
|
||||||
|
def _reading(self, t1: float, t2: float, seq: int = 0) -> TemperatureReading:
|
||||||
|
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
|
||||||
|
|
||||||
|
def test_laser1_off_target_warns_once_for_laser1(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=28.05, t2=28.9)) # laser1 off by 0.05
|
||||||
|
self.assertEqual([d.laser for d in warned], [1])
|
||||||
|
|
||||||
|
def test_within_tolerance_no_warning(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=28.01, t2=28.9)) # 0.01 < 0.03
|
||||||
|
self.assertEqual(warned, [])
|
||||||
|
|
||||||
|
def test_boundary_equal_tolerance_is_ok(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=28.03, t2=28.9)) # |Δ|==tol -> within
|
||||||
|
self.assertEqual(warned, [])
|
||||||
|
|
||||||
|
def test_both_lasers_off_target_warn_independently(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=27.9, t2=29.0))
|
||||||
|
self.assertEqual(sorted(d.laser for d in warned), [1, 2])
|
||||||
|
|
||||||
|
def test_persistent_mismatch_warns_once_then_silent(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
first = checker.process(self._reading(t1=28.1, t2=28.9, seq=0))
|
||||||
|
second = checker.process(self._reading(t1=28.1, t2=28.9, seq=1))
|
||||||
|
self.assertEqual([d.laser for d in first], [1])
|
||||||
|
self.assertEqual(second, []) # no reminder configured
|
||||||
|
|
||||||
|
def test_reminder_repeats_warning(self) -> None:
|
||||||
|
checker = self._checker(reminder_every=2)
|
||||||
|
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # initial warn
|
||||||
|
self.assertEqual(checker.process(self._reading(t1=28.1, t2=28.9, seq=1)), [])
|
||||||
|
again = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # reminder
|
||||||
|
self.assertEqual([d.laser for d in again], [1])
|
||||||
|
|
||||||
|
def test_recovery_clears_mismatch_state(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # warn
|
||||||
|
checker.process(self._reading(t1=28.0, t2=28.9, seq=1)) # recover (info, no warn)
|
||||||
|
rewarn = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # warns again
|
||||||
|
self.assertEqual([d.laser for d in rewarn], [1])
|
||||||
|
|
||||||
|
def test_from_session_uses_session_targets(self) -> None:
|
||||||
|
session = LaserVariationSession(
|
||||||
|
variation_type="CHANGE_CURRENT_LD1",
|
||||||
|
target_temp1=30.0, target_temp2=31.0, tolerance_c=0.03,
|
||||||
|
)
|
||||||
|
checker = LaserTemperatureChecker.from_session(session)
|
||||||
|
warned = checker.process(self._reading(t1=30.1, t2=31.0))
|
||||||
|
self.assertEqual([d.laser for d in warned], [1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -161,8 +161,8 @@ class WebControllerTest(unittest.TestCase):
|
|||||||
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
||||||
received: list[dict] = []
|
received: list[dict] = []
|
||||||
self.controller.apply_settings_requested.connect(received.append)
|
self.controller.apply_settings_requested.connect(received.append)
|
||||||
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
|
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
|
||||||
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
|
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
|
||||||
self.assertIsInstance(out, list)
|
self.assertIsInstance(out, list)
|
||||||
|
|
||||||
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
||||||
|
|||||||
@@ -183,6 +183,16 @@ class SequentialCaptureSession:
|
|||||||
if self._config.runtime.settling_ms > 0:
|
if self._config.runtime.settling_ms > 0:
|
||||||
time.sleep(self._config.runtime.settling_ms / 1000.0)
|
time.sleep(self._config.runtime.settling_ms / 1000.0)
|
||||||
|
|
||||||
|
# A free-running streaming radar (Kamil ADC) keeps a sweep captured in the
|
||||||
|
# previous combination buffered, and may have a transition-straddling sweep
|
||||||
|
# in flight. Drop those so this capture holds data from the new switch state
|
||||||
|
# — otherwise the trace is labelled with this combo but carries the previous
|
||||||
|
# one's data (an off-by-one across the sequence). Discrete radars (LibreVNA)
|
||||||
|
# acquire a fresh sweep per call and expose no such method, so skip them.
|
||||||
|
drain_after_switch = getattr(self._radar, "drain_after_switch", None)
|
||||||
|
if callable(drain_after_switch):
|
||||||
|
drain_after_switch()
|
||||||
|
|
||||||
sweep_traces: list[TraceData] = []
|
sweep_traces: list[TraceData] = []
|
||||||
for _ in range(self._median_sweep_count):
|
for _ in range(self._median_sweep_count):
|
||||||
sweep = self._radar.acquire()
|
sweep = self._radar.acquire()
|
||||||
|
|||||||
+2
-2
@@ -414,7 +414,7 @@
|
|||||||
"min_depth_m": 2.0,
|
"min_depth_m": 2.0,
|
||||||
"max_depth_m": 14.0,
|
"max_depth_m": 14.0,
|
||||||
"range_comp_power": 0.1,
|
"range_comp_power": 0.1,
|
||||||
"angle_comp_power": 0.0,
|
"object_min_frac": 0.7,
|
||||||
"score_mode": "combined",
|
"score_mode": "combined",
|
||||||
"motion_mode": "int_minus",
|
"motion_mode": "int_minus",
|
||||||
"look_angle_deg": 0.0,
|
"look_angle_deg": 0.0,
|
||||||
@@ -423,6 +423,7 @@
|
|||||||
"ignore_socket_speed_enabled": false,
|
"ignore_socket_speed_enabled": false,
|
||||||
"max_detected_objects_to_draw": 5,
|
"max_detected_objects_to_draw": 5,
|
||||||
"draw_top_m_objects": 2,
|
"draw_top_m_objects": 2,
|
||||||
|
"object_approach_min_frames": 3,
|
||||||
"start_freq_mhz": 3000.0,
|
"start_freq_mhz": 3000.0,
|
||||||
"stop_freq_mhz": 6000.0,
|
"stop_freq_mhz": 6000.0,
|
||||||
"background_subtract_enabled": true,
|
"background_subtract_enabled": true,
|
||||||
@@ -430,7 +431,6 @@
|
|||||||
"remove_sidelobe_objects_enabled": false,
|
"remove_sidelobe_objects_enabled": false,
|
||||||
"imaging_plane_y_m": 0.0,
|
"imaging_plane_y_m": 0.0,
|
||||||
"render_mode": "heatmap",
|
"render_mode": "heatmap",
|
||||||
"min_visible_score": 0.0,
|
|
||||||
"visible_x_min_m": -2.0,
|
"visible_x_min_m": -2.0,
|
||||||
"visible_x_max_m": 2.0,
|
"visible_x_max_m": 2.0,
|
||||||
"visible_z_min_m": 0.0,
|
"visible_z_min_m": 0.0,
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "librevna_multi",
|
||||||
|
"serial": "206830985532",
|
||||||
|
"remote_host": "127.0.0.1",
|
||||||
|
"remote_port": 50209,
|
||||||
|
"driver_mode": "native",
|
||||||
|
"mock_signal_hz": 5000000.0,
|
||||||
|
"visa_library": "",
|
||||||
|
"multi_device": {
|
||||||
|
"slave_serials": [
|
||||||
|
"206930965532",
|
||||||
|
"206930A15532"
|
||||||
|
],
|
||||||
|
"force_external_reference": true,
|
||||||
|
"recovery_attempts": 3
|
||||||
|
},
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": "",
|
||||||
|
"executable_path": "",
|
||||||
|
"tty_path": "",
|
||||||
|
"args": [],
|
||||||
|
"env": {},
|
||||||
|
"startup_timeout_s": 5.0,
|
||||||
|
"sweep_timeout_s": 5.0,
|
||||||
|
"stop_timeout_s": 2.0
|
||||||
|
},
|
||||||
|
"laser_control": {
|
||||||
|
"enabled": false,
|
||||||
|
"port": "",
|
||||||
|
"mode": "manual",
|
||||||
|
"pi_coeff1_p": 2560,
|
||||||
|
"pi_coeff1_i": 128,
|
||||||
|
"pi_coeff2_p": 2560,
|
||||||
|
"pi_coeff2_i": 128,
|
||||||
|
"manual": {
|
||||||
|
"temp1": 25.0,
|
||||||
|
"temp2": 25.0,
|
||||||
|
"current1": 30.0,
|
||||||
|
"current2": 30.0
|
||||||
|
},
|
||||||
|
"variation": {
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"static_temp1": 25.0,
|
||||||
|
"static_temp2": 25.0,
|
||||||
|
"static_current1": 30.0,
|
||||||
|
"static_current2": 30.0,
|
||||||
|
"min_value": 30.0,
|
||||||
|
"max_value": 35.0,
|
||||||
|
"step": 0.1,
|
||||||
|
"time_step": 20,
|
||||||
|
"delay_time": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sweep": {
|
||||||
|
"start_hz": 1000000.0,
|
||||||
|
"stop_hz": 6000000000.0,
|
||||||
|
"if_bandwidth_hz": 50000.0,
|
||||||
|
"stimulus_power_dbm": -10.0,
|
||||||
|
"points": 201
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switches": {
|
||||||
|
"port1": {
|
||||||
|
"name": "port1",
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"driver": "h7992",
|
||||||
|
"radar_port": 1,
|
||||||
|
"positions": 2,
|
||||||
|
"default_position": 0,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin_a": 17,
|
||||||
|
"pin_b": 27,
|
||||||
|
"invert_logic": false
|
||||||
|
},
|
||||||
|
"port2": {
|
||||||
|
"name": "port2",
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"driver": "h7992",
|
||||||
|
"radar_port": 2,
|
||||||
|
"positions": 4,
|
||||||
|
"default_position": 0,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin_a": 22,
|
||||||
|
"pin_b": 23,
|
||||||
|
"invert_logic": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"control_button": {
|
||||||
|
"enabled": false,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin": -1,
|
||||||
|
"active_low": true,
|
||||||
|
"bias": "",
|
||||||
|
"debounce_ms": 50,
|
||||||
|
"action": "capture_tmp_reference"
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"settling_ms": 0,
|
||||||
|
"idle_sleep_ms": 2,
|
||||||
|
"continuous": true,
|
||||||
|
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||||
|
"locator_server": {
|
||||||
|
"device_id": 3,
|
||||||
|
"protocol_version": 1,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8888,
|
||||||
|
"max_payload_bytes": 65536,
|
||||||
|
"client_queue_size": 32,
|
||||||
|
"logger_name": "locator_runtime"
|
||||||
|
},
|
||||||
|
"combos": [
|
||||||
|
{
|
||||||
|
"input": 0,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 1,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 0,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 1,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preprocess": {
|
||||||
|
"s21": {
|
||||||
|
"calibration": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s11": {
|
||||||
|
"calibration": {
|
||||||
|
"open": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"short": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"load": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notch": {
|
||||||
|
"enabled": true,
|
||||||
|
"bands_hz": [],
|
||||||
|
"taper_width_hz": 40000000.0,
|
||||||
|
"taper_type": "cosine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gpr": {
|
||||||
|
"relative_permittivity": 1.0,
|
||||||
|
"tx_geometry": [
|
||||||
|
{
|
||||||
|
"output_pos": 0,
|
||||||
|
"x_m": 0.905,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_pos": 1,
|
||||||
|
"x_m": -0.905,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rx_geometry": [
|
||||||
|
{
|
||||||
|
"input_pos": 0,
|
||||||
|
"x_m": -0.18,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 1,
|
||||||
|
"x_m": 0.485,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 2,
|
||||||
|
"x_m": -0.49,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 3,
|
||||||
|
"x_m": 0.185,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rings": {
|
||||||
|
"raw": {
|
||||||
|
"name": "/radar_raw",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"raw_tap": {
|
||||||
|
"name": "/radar_raw_tap",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"preprocessed": {
|
||||||
|
"name": "/radar_preprocessed",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"preprocessed_tap": {
|
||||||
|
"name": "/radar_preprocessed_tap",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"name": "/radar_results",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
"min_depth_m": 2.0,
|
"min_depth_m": 2.0,
|
||||||
"max_depth_m": 14.0,
|
"max_depth_m": 14.0,
|
||||||
"range_comp_power": 0.1,
|
"range_comp_power": 0.1,
|
||||||
"angle_comp_power": 0.0,
|
"object_min_frac": 0.7,
|
||||||
"score_mode": "combined",
|
"score_mode": "combined",
|
||||||
"motion_mode": "int_minus",
|
"motion_mode": "int_minus",
|
||||||
"look_angle_deg": 0.0,
|
"look_angle_deg": 0.0,
|
||||||
@@ -291,6 +291,7 @@
|
|||||||
"ignore_socket_speed_enabled": false,
|
"ignore_socket_speed_enabled": false,
|
||||||
"max_detected_objects_to_draw": 5,
|
"max_detected_objects_to_draw": 5,
|
||||||
"draw_top_m_objects": 2,
|
"draw_top_m_objects": 2,
|
||||||
|
"object_approach_min_frames": 3,
|
||||||
"start_freq_mhz": 3000.0,
|
"start_freq_mhz": 3000.0,
|
||||||
"stop_freq_mhz": 6000.0,
|
"stop_freq_mhz": 6000.0,
|
||||||
"background_subtract_enabled": true,
|
"background_subtract_enabled": true,
|
||||||
@@ -298,7 +299,6 @@
|
|||||||
"remove_sidelobe_objects_enabled": false,
|
"remove_sidelobe_objects_enabled": false,
|
||||||
"imaging_plane_y_m": 0.0,
|
"imaging_plane_y_m": 0.0,
|
||||||
"render_mode": "heatmap",
|
"render_mode": "heatmap",
|
||||||
"min_visible_score": 0.0,
|
|
||||||
"visible_x_min_m": -2.0,
|
"visible_x_min_m": -2.0,
|
||||||
"visible_x_max_m": 2.0,
|
"visible_x_max_m": 2.0,
|
||||||
"visible_z_min_m": 0.0,
|
"visible_z_min_m": 0.0,
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
"min_depth_m": 2.0,
|
"min_depth_m": 2.0,
|
||||||
"max_depth_m": 14.0,
|
"max_depth_m": 14.0,
|
||||||
"range_comp_power": 0.1,
|
"range_comp_power": 0.1,
|
||||||
"angle_comp_power": 0.0,
|
"object_min_frac": 0.7,
|
||||||
"score_mode": "combined",
|
"score_mode": "combined",
|
||||||
"motion_mode": "int_minus",
|
"motion_mode": "int_minus",
|
||||||
"look_angle_deg": 0.0,
|
"look_angle_deg": 0.0,
|
||||||
@@ -291,6 +291,7 @@
|
|||||||
"ignore_socket_speed_enabled": false,
|
"ignore_socket_speed_enabled": false,
|
||||||
"max_detected_objects_to_draw": 5,
|
"max_detected_objects_to_draw": 5,
|
||||||
"draw_top_m_objects": 2,
|
"draw_top_m_objects": 2,
|
||||||
|
"object_approach_min_frames": 3,
|
||||||
"start_freq_mhz": 3000.0,
|
"start_freq_mhz": 3000.0,
|
||||||
"stop_freq_mhz": 6000.0,
|
"stop_freq_mhz": 6000.0,
|
||||||
"background_subtract_enabled": true,
|
"background_subtract_enabled": true,
|
||||||
@@ -298,7 +299,6 @@
|
|||||||
"remove_sidelobe_objects_enabled": false,
|
"remove_sidelobe_objects_enabled": false,
|
||||||
"imaging_plane_y_m": 0.0,
|
"imaging_plane_y_m": 0.0,
|
||||||
"render_mode": "heatmap",
|
"render_mode": "heatmap",
|
||||||
"min_visible_score": 0.0,
|
|
||||||
"visible_x_min_m": -2.0,
|
"visible_x_min_m": -2.0,
|
||||||
"visible_x_max_m": 2.0,
|
"visible_x_max_m": 2.0,
|
||||||
"visible_z_min_m": 0.0,
|
"visible_z_min_m": 0.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user