Compare commits
10
Commits
d7ddca6e76
...
52c1218bf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c1218bf7 | ||
|
|
3efe968dd1 | ||
|
|
42532c9868 | ||
|
|
4ca4b27246 | ||
|
|
f967da7f53 | ||
|
|
9d30fab534 | ||
|
|
d522828875 | ||
|
|
02bbe83446 | ||
|
|
8db14b9482 | ||
|
|
716fd0b07a |
@@ -8,6 +8,7 @@ build*
|
||||
# C extensions
|
||||
python_app/data*
|
||||
*.so
|
||||
*.ipynb
|
||||
*.npy
|
||||
snapshots/
|
||||
test_results*
|
||||
@@ -224,3 +225,6 @@ __marimo__/
|
||||
.streamlit/secrets.toml
|
||||
python_app/runtime
|
||||
SHARE_INTERNET_TO_PI.md
|
||||
|
||||
CLAUDE.md
|
||||
./docs
|
||||
@@ -65,17 +65,36 @@ PROCESSOR_SOURCES := \
|
||||
# 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.
|
||||
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
|
||||
# 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_DIR)/src/main.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))
|
||||
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))
|
||||
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))
|
||||
|
||||
TARGETS := \
|
||||
@@ -101,11 +120,15 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS)
|
||||
|
||||
# The collector is self-contained: compile its objects with only the vendored
|
||||
# L-Card headers (no project/VISA includes) by overriding the generic rule's
|
||||
# variables for these objects, then link with dlopen/openpty support.
|
||||
$(KAMIL_COLLECTOR_OBJS): INCLUDES := $(KAMIL_COLLECTOR_INCLUDES)
|
||||
$(KAMIL_COLLECTOR_OBJS): VISA_CXXFLAGS :=
|
||||
# The collector compiles into its own build/kamil/ tree with collector-only
|
||||
# includes (no VISA), so the shared driver/config sources it reuses never collide
|
||||
# with the orchestrator's objects of the same name. Basenames are unique, so a
|
||||
# vpath lets one pattern rule find every source.
|
||||
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)
|
||||
@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;
|
||||
|
||||
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
|
||||
// st_size covers the geometry before trusting the mapping.
|
||||
struct stat info {};
|
||||
@@ -257,7 +257,7 @@ auto ShmRing::open_existing(const std::string& name) -> ShmRing {
|
||||
if (header->version != kRingVersion) {
|
||||
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);
|
||||
|
||||
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) {
|
||||
// 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
|
||||
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
|
||||
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 "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 <array>
|
||||
#include <atomic>
|
||||
@@ -113,6 +118,12 @@ struct Config {
|
||||
std::string live_html_path = "live_plot.html";
|
||||
std::string live_json_path = "live_plot.json";
|
||||
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 do1_toggle_per_frame = false;
|
||||
bool do1_noise_subtract = false;
|
||||
@@ -479,6 +490,7 @@ void print_help(const char* exe_name) {
|
||||
<< " [di1:zero|trace|ignore]\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"
|
||||
<< " [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"
|
||||
<< " [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"
|
||||
@@ -514,6 +526,13 @@ void print_help(const char* exe_name) {
|
||||
<< " 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"
|
||||
<< " 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"
|
||||
<< " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1203,6 +1234,10 @@ constexpr uint32_t kDo1TogglePeriodTicks = 2U;
|
||||
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
|
||||
constexpr uint32_t kDo8HighTicks = 2U;
|
||||
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 kStreamInputCalibratedAdcFlag = 0x40000000U;
|
||||
|
||||
@@ -1802,6 +1837,55 @@ void print_device_info(const t_x502_info& info) {
|
||||
<< "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) {
|
||||
Api api;
|
||||
DeviceHandle device(api);
|
||||
@@ -2029,6 +2113,21 @@ int run(const Config& cfg) {
|
||||
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;
|
||||
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);
|
||||
@@ -2317,6 +2416,15 @@ int run(const Config& cfg) {
|
||||
|
||||
auto append_tty_packet_start = [&]() {
|
||||
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 = [&]() {
|
||||
@@ -2714,6 +2822,12 @@ int run(const Config& cfg) {
|
||||
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_avg_steps = 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_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);
|
||||
if (recvd < 0) {
|
||||
@@ -3170,6 +3289,11 @@ int run(const Config& cfg) {
|
||||
|
||||
expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
|
||||
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) {
|
||||
const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
|
||||
expect_ok(api,
|
||||
|
||||
+9
-5
@@ -45,8 +45,10 @@ struct ProcessingLiveConfig {
|
||||
float gpr_min_depth_m = 2.0F;
|
||||
float gpr_max_depth_m = 14.0F;
|
||||
float gpr_range_comp_power = 0.1F;
|
||||
float gpr_angle_comp_power = 0.0F;
|
||||
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";
|
||||
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
||||
// 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.
|
||||
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
|
||||
float gpr_imaging_plane_y_m = 0.0F;
|
||||
// Locator filter parameters. Mode-dependent threshold (legacy_gpr uses
|
||||
// `legacy_gpr_min_visible_pair_count`, everything else uses
|
||||
// `gpr_min_visible_score`). Draw limits apply only to non-legacy modes.
|
||||
float gpr_min_visible_score = 0.0F;
|
||||
// Coherent BP object visibility (window + the draw limits below) is applied in
|
||||
// the processor itself, matching Horns_motion_3libre.py — there is NO score
|
||||
// threshold for it. Only legacy GPR still thresholds, on a pair count.
|
||||
float legacy_gpr_min_visible_pair_count = 0.0F;
|
||||
std::uint32_t gpr_max_detected_objects_to_draw = 0;
|
||||
std::uint32_t gpr_draw_top_m_objects = 0;
|
||||
// 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
|
||||
// window so the socket emits only what the desktop plot actually shows.
|
||||
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 error_count = 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
|
||||
// the current result (gated below by reprocess_current_result).
|
||||
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);
|
||||
}
|
||||
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.
|
||||
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;
|
||||
|
||||
radar::locator::FilterParams filter{};
|
||||
// Clip broadcast objects to the same visible X/Z window the desktop plot uses,
|
||||
// so the socket emits only the objects the operator actually sees.
|
||||
filter.visible_bounds = radar::locator::VisibleBounds{
|
||||
.x_min = live_config.gpr_visible_x_min_m,
|
||||
.x_max = live_config.gpr_visible_x_max_m,
|
||||
.z_min = live_config.gpr_visible_z_min_m,
|
||||
.z_max = live_config.gpr_visible_z_max_m,
|
||||
};
|
||||
if (requested_mode == "legacy_gpr") {
|
||||
// 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{
|
||||
.x_min = live_config.gpr_visible_x_min_m,
|
||||
.x_max = live_config.gpr_visible_x_max_m,
|
||||
.z_min = live_config.gpr_visible_z_min_m,
|
||||
.z_max = live_config.gpr_visible_z_max_m,
|
||||
};
|
||||
filter.min_score = live_config.legacy_gpr_min_visible_pair_count;
|
||||
// The GUI deliberately disables the "draw top N" capping for legacy
|
||||
// GPR, so we also skip it on the wire to match observation semantics.
|
||||
filter.draw_limits.reset();
|
||||
} else {
|
||||
filter.min_score = live_config.gpr_min_visible_score;
|
||||
if (live_config.gpr_max_detected_objects_to_draw > 0U
|
||||
&& live_config.gpr_draw_top_m_objects > 0U) {
|
||||
filter.draw_limits = radar::locator::DrawLimits{
|
||||
.max_detected_objects = live_config.gpr_max_detected_objects_to_draw,
|
||||
.draw_top_objects = live_config.gpr_draw_top_m_objects,
|
||||
};
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
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()) {
|
||||
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 (!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>());
|
||||
}
|
||||
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] : {
|
||||
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},
|
||||
@@ -388,6 +382,10 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
||||
config.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 (!found->is_boolean()) {
|
||||
throw std::runtime_error("processing.ignore_socket_speed must be bool");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "object_approach_filter.hpp"
|
||||
#include "processor_interface.hpp"
|
||||
|
||||
namespace radar::processing {
|
||||
@@ -13,6 +14,11 @@ class GprProcessor final : public ProcessorInterface {
|
||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||
const ProcessingLiveConfig& live_config
|
||||
) -> 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 {
|
||||
|
||||
@@ -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.
|
||||
constexpr double kGaussianTruncate = 4.0;
|
||||
constexpr std::size_t kMaxObjects = 10U;
|
||||
constexpr double kObjectMinFrac = 0.7;
|
||||
constexpr double kRegionThresholdFrac = 0.75;
|
||||
constexpr double kSuppressThresholdFrac = 0.20;
|
||||
constexpr double kSuppressRadiusXM = 0.80;
|
||||
@@ -1405,7 +1404,8 @@ void apply_depth_gate(
|
||||
|
||||
[[nodiscard]] auto find_bp_objects(
|
||||
const std::vector<double>& bp_image,
|
||||
const GridDefinition& grid
|
||||
const GridDefinition& grid,
|
||||
double min_frac
|
||||
) -> std::vector<ObjectRecord> {
|
||||
std::vector<ObjectRecord> objects{};
|
||||
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;
|
||||
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)) {
|
||||
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 ProcessingLiveConfig& live_config
|
||||
) -> std::vector<const ObjectRecord*> {
|
||||
@@ -1750,6 +1758,12 @@ void add_bp_score_metrics(
|
||||
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1759,6 +1773,17 @@ void add_bp_score_metrics(
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1815,7 +1840,8 @@ void add_bp_score_metrics(
|
||||
const config::RunConfig& run_config,
|
||||
const ipc::PreprocessedCollection& collection,
|
||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||
const ProcessingLiveConfig& live_config
|
||||
const ProcessingLiveConfig& live_config,
|
||||
ObjectApproachFilter& approach_filter
|
||||
) -> ipc::ResultCollection {
|
||||
ipc::ResultCollection results{};
|
||||
results.collection_id = collection.collection_id;
|
||||
@@ -1833,8 +1859,10 @@ void add_bp_score_metrics(
|
||||
return results;
|
||||
}
|
||||
|
||||
const double velocity_mps =
|
||||
kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast<double>(run_config.gpr.relative_permittivity)));
|
||||
// Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python
|
||||
// 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 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);
|
||||
@@ -1920,7 +1948,7 @@ void add_bp_score_metrics(
|
||||
min_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_angle_comp_power))
|
||||
0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0)
|
||||
);
|
||||
if (bp.image.empty()) {
|
||||
return results;
|
||||
@@ -1930,7 +1958,7 @@ void add_bp_score_metrics(
|
||||
const auto incoherent_display_map =
|
||||
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_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
|
||||
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));
|
||||
|
||||
// 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{};
|
||||
point_rows.reserve(objects.size());
|
||||
for (const auto* object : output_objects_sorted(objects, live_config)) {
|
||||
point_rows.reserve(visible.size());
|
||||
for (std::size_t index = 0U; index < visible.size(); ++index) {
|
||||
if (!confirmed[index]) {
|
||||
continue;
|
||||
}
|
||||
point_rows.push_back(
|
||||
{
|
||||
static_cast<float>(object->x_m),
|
||||
static_cast<float>(object->z_m),
|
||||
static_cast<float>(object->selected_score),
|
||||
static_cast<float>(visible[index]->x_m),
|
||||
static_cast<float>(visible[index]->z_m),
|
||||
static_cast<float>(visible[index]->selected_score),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ auto GprProcessor::process_collection(
|
||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||
const ProcessingLiveConfig& live_config
|
||||
) -> 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 {
|
||||
|
||||
@@ -207,7 +207,7 @@ SweepOrchestrator::SweepOrchestrator(
|
||||
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
|
||||
// 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);
|
||||
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
|
||||
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_);
|
||||
// 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.
|
||||
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
||||
return; // stop requested before any device became available
|
||||
|
||||
@@ -25,6 +25,7 @@ from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMi
|
||||
from python_app.gui.controllers.app_window_control_button_mixin import AppWindowControlButtonMixin
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin
|
||||
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
|
||||
@@ -96,6 +97,7 @@ class AppWindow(
|
||||
AppWindowPlotMixin,
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
AppWindowRecordingMixin,
|
||||
AppWindowControlButtonMixin,
|
||||
AppWindowWebMixin,
|
||||
QMainWindow,
|
||||
@@ -114,6 +116,7 @@ class AppWindow(
|
||||
self._init_preprocess_state()
|
||||
self._init_capture_state()
|
||||
self._init_history_state()
|
||||
self._init_recording_state()
|
||||
self._init_runtime_limits()
|
||||
self._init_polling_timer()
|
||||
self._init_control_button_state()
|
||||
@@ -676,9 +679,27 @@ class AppWindow(
|
||||
return max(0, int(raw_value))
|
||||
return 0
|
||||
|
||||
def _capture_web_action_error(self, message: str) -> bool:
|
||||
"""Record the first error of an in-flight web action so the browser can show it.
|
||||
|
||||
Returns ``True`` if a web-triggered action is currently running (see
|
||||
``AppWindowWebMixin._run_web_action``). Callers use that to skip the blocking
|
||||
desktop modal for web errors — the browser shows the message instead, and the
|
||||
operator at the browser must not have to dismiss a popup on the (often headless)
|
||||
host before the HTTP response returns. Desktop-only errors are unaffected.
|
||||
"""
|
||||
capture = getattr(self, "_web_action_error_capture", None)
|
||||
if capture is None:
|
||||
return False
|
||||
if not capture:
|
||||
capture.append(message)
|
||||
return True
|
||||
|
||||
def _show_error(self, message: str, *, details: str | None = None) -> None:
|
||||
"""Log and present an error in a modal dialog with optional detail text."""
|
||||
self._log_error(message, details=details)
|
||||
if self._capture_web_action_error(message):
|
||||
return # web-triggered: surfaced to the browser; no blocking desktop modal
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
return
|
||||
dialog = QMessageBox(self)
|
||||
@@ -692,6 +713,8 @@ class AppWindow(
|
||||
def _show_exception(self, context: str, exc: Exception) -> None:
|
||||
"""Log full exception details and show modal dialog with expandable traceback."""
|
||||
message, details = self._log_exception(context, exc, level="ERROR")
|
||||
if self._capture_web_action_error(message):
|
||||
return # web-triggered: surfaced to the browser; no blocking desktop modal
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
return
|
||||
dialog = QMessageBox(self)
|
||||
@@ -717,6 +740,8 @@ class AppWindow(
|
||||
try:
|
||||
# 0) Stop the web server first so a late request cannot start work.
|
||||
self._shutdown_web_ui()
|
||||
# 0) Stop the disk-recording writer thread so it is not orphaned.
|
||||
self._shutdown_recording()
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
|
||||
@@ -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_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_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")),
|
||||
("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_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_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_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_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
||||
("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
|
||||
# "applies on Start" and editing them just updates the widget for the next start.
|
||||
_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")),
|
||||
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
|
||||
]
|
||||
@@ -475,14 +477,13 @@ class AppWindowLiveProcessingMixin:
|
||||
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"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"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
||||
f"mean_count={self._gpr_background_mean_count.value()}, "
|
||||
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
|
||||
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
|
||||
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"draw_top={self._gpr_draw_top_m_objects.value()})"
|
||||
)
|
||||
|
||||
@@ -291,7 +291,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._gpr_min_depth_m,
|
||||
self._gpr_max_depth_m,
|
||||
self._gpr_range_comp_power,
|
||||
self._gpr_angle_comp_power,
|
||||
self._gpr_object_min_frac,
|
||||
self._gpr_score_mode,
|
||||
self._gpr_motion_mode,
|
||||
self._gpr_look_angle_deg,
|
||||
@@ -300,6 +300,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._gpr_speed_m_s,
|
||||
self._gpr_max_detected_objects_to_draw,
|
||||
self._gpr_draw_top_m_objects,
|
||||
self._gpr_object_approach_min_frames,
|
||||
self._gpr_start_freq_mhz,
|
||||
self._gpr_stop_freq_mhz,
|
||||
self._gpr_background_subtract_enabled,
|
||||
@@ -307,7 +308,6 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._gpr_remove_sidelobe_objects_enabled,
|
||||
self._gpr_imaging_plane_y_m,
|
||||
self._gpr_render_mode,
|
||||
self._gpr_min_visible_score,
|
||||
self._gpr_visible_x_min_m,
|
||||
self._gpr_visible_x_max_m,
|
||||
self._gpr_visible_z_min_m,
|
||||
@@ -336,6 +336,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._legacy_gpr_visible_z_min_m,
|
||||
self._legacy_gpr_visible_z_max_m,
|
||||
self._save_count,
|
||||
self._record_count,
|
||||
self._save_path_input,
|
||||
self._save_name_input,
|
||||
self._adc_project_dir_input,
|
||||
@@ -456,7 +457,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
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_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_motion_mode, gui_state.processing.gpr.motion_mode)
|
||||
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
||||
@@ -469,6 +470,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
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_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_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
||||
self._gpr_background_subtract_enabled.setChecked(
|
||||
@@ -480,7 +482,6 @@ class AppWindowConfigProfileIOMixin:
|
||||
)
|
||||
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._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_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))
|
||||
@@ -522,6 +523,7 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._legacy_gpr_visible_z_max_m.setValue(float(gui_state.processing.legacy_gpr.visible_z_max_m))
|
||||
|
||||
self._save_count.setValue(int(gui_state.data_actions.save_count))
|
||||
self._record_count.setValue(int(gui_state.data_actions.record_count))
|
||||
self._save_path_input.setText(str(gui_state.data_actions.save_path))
|
||||
self._save_name_input.setText(str(gui_state.data_actions.save_name))
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
min_depth_m=2.0,
|
||||
max_depth_m=14.0,
|
||||
range_comp_power=0.1,
|
||||
angle_comp_power=0.0,
|
||||
object_min_frac=0.7,
|
||||
score_mode="combined",
|
||||
motion_mode="int_minus",
|
||||
look_angle_deg=0.0,
|
||||
@@ -233,6 +233,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
ignore_socket_speed_enabled=False,
|
||||
max_detected_objects_to_draw=5,
|
||||
draw_top_m_objects=2,
|
||||
object_approach_min_frames=3,
|
||||
start_freq_mhz=3000.0,
|
||||
stop_freq_mhz=6000.0,
|
||||
background_subtract_enabled=True,
|
||||
@@ -240,7 +241,6 @@ class AppWindowConfigStateBuildersMixin:
|
||||
remove_sidelobe_objects_enabled=True,
|
||||
imaging_plane_y_m=0.0,
|
||||
render_mode="heatmap",
|
||||
min_visible_score=0.0,
|
||||
visible_x_min_m=default_gpr_x_min_m,
|
||||
visible_x_max_m=default_gpr_x_max_m,
|
||||
visible_z_min_m=0.0,
|
||||
@@ -273,6 +273,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
save_count=10,
|
||||
save_path=str(self._project_root / "python_app/data/snapshots"),
|
||||
save_name="snapshot_manual",
|
||||
record_count=100,
|
||||
),
|
||||
preprocess_dialog=GuiPreprocessDialogStateModel(
|
||||
set_name="set_001",
|
||||
@@ -350,7 +351,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||
max_depth_m=float(self._gpr_max_depth_m.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(),
|
||||
motion_mode=self._gpr_motion_mode.currentText(),
|
||||
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
||||
@@ -359,6 +360,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
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()),
|
||||
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()),
|
||||
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
||||
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||
@@ -366,7 +368,6 @@ class AppWindowConfigStateBuildersMixin:
|
||||
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
||||
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||
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_max_m=float(self._gpr_visible_x_max_m.value()),
|
||||
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
||||
@@ -404,6 +405,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
save_count=int(self._save_count.value()),
|
||||
save_path=self._save_path_input.text().strip(),
|
||||
save_name=self._save_name_input.text().strip(),
|
||||
record_count=int(self._record_count.value()),
|
||||
),
|
||||
preprocess_dialog=GuiPreprocessDialogStateModel(
|
||||
set_name=self._current_preprocess_set_name(),
|
||||
|
||||
@@ -281,6 +281,9 @@ class AppWindowPipelineMixin:
|
||||
else:
|
||||
self._supervisor.stop()
|
||||
self._drain_rings_once_for_history()
|
||||
# Flush any in-progress disk recording so its buffered (not-yet-full) chunk is
|
||||
# written rather than lost; the drains above already captured the last data.
|
||||
self._finalize_recording_on_stop()
|
||||
keep_results_reader = self._supervisor.is_processor_running()
|
||||
self._close_readers(keep_results=keep_results_reader)
|
||||
self._single_capture_active = False
|
||||
@@ -340,6 +343,7 @@ class AppWindowPipelineMixin:
|
||||
self._read_all_raw()
|
||||
self._read_all_preprocessed()
|
||||
result_latest = self._read_all_results() if self._result_reader is not None else None
|
||||
self._poll_recording_writer() # finalize a disk recording once its writer drains
|
||||
self._update_history_indicator()
|
||||
self._last_reader_error_signature = None
|
||||
self._reader_error_repeat_count = 0
|
||||
@@ -550,6 +554,7 @@ class AppWindowPipelineMixin:
|
||||
if collection is None:
|
||||
break
|
||||
self._raw_history.append(collection)
|
||||
self._record_collection("raw", collection)
|
||||
latest = collection
|
||||
# `capture_*_ns` are populated by the C++ sweep_orchestrator with
|
||||
# wallclocks captured around the actual device read. Pre-orchestrator
|
||||
@@ -573,6 +578,7 @@ class AppWindowPipelineMixin:
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
self._record_collection("preprocessed", collection)
|
||||
|
||||
def _read_all_results(self) -> ResultCollection | None:
|
||||
"""Read available result collections from results ring."""
|
||||
@@ -585,6 +591,7 @@ class AppWindowPipelineMixin:
|
||||
break
|
||||
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
|
||||
record_result_history(self._result_history, collection)
|
||||
self._record_collection("results", collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import pyqtgraph as pg
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
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_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
||||
filter_object_rows as gpr_filter_object_rows,
|
||||
@@ -371,26 +370,6 @@ class AppWindowGprPlotMixin:
|
||||
return self._legacy_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
|
||||
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||
"""Return lower display bound, preserving surface markers only when surface is visible."""
|
||||
@@ -506,18 +485,24 @@ class AppWindowGprPlotMixin:
|
||||
return extract_gpr_object_rows(collection)
|
||||
|
||||
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)
|
||||
if rows.size == 0:
|
||||
if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr":
|
||||
return rows
|
||||
|
||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||
return gpr_filter_object_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),
|
||||
z_bounds=(z_min, z_max),
|
||||
draw_limits=self._gpr_draw_limits(),
|
||||
draw_limits=None,
|
||||
)
|
||||
|
||||
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.gui.trace_png_export import export_trace_png
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_SPECS,
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
@@ -701,6 +702,12 @@ class AppWindowPreprocessMixin:
|
||||
f"{display_name} sequence completed and saved: set={set_name}, "
|
||||
f"radar_variants={len(saved_sets)} [{saved_summary}]"
|
||||
)
|
||||
self._export_multi_radar_preview_pngs(
|
||||
session=session,
|
||||
saved_sets=saved_sets,
|
||||
kind=kind,
|
||||
set_name=set_name,
|
||||
)
|
||||
else:
|
||||
dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)")
|
||||
self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}")
|
||||
@@ -708,6 +715,50 @@ class AppWindowPreprocessMixin:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to save preprocess set", exc)
|
||||
|
||||
def _export_multi_radar_preview_pngs(
|
||||
self,
|
||||
*,
|
||||
session: MultiRadarSequentialCaptureSession,
|
||||
saved_sets: list,
|
||||
kind: str,
|
||||
set_name: str,
|
||||
) -> None:
|
||||
"""Save amplitude/phase PNGs for every captured combo across all radar configs.
|
||||
|
||||
The live preview only shows one trace per config, so this persists a graph for
|
||||
every config and every combo (port) under the store's ``preview_png/`` tree.
|
||||
Iterating per config over its full trace set (not the per-batch preview trace)
|
||||
is what makes matrix radars save all ports instead of only the last one. The
|
||||
set is already saved by the time we get here, so any rendering failure is
|
||||
logged but never aborts the save.
|
||||
"""
|
||||
channel = preprocess_asset_channel(kind)
|
||||
display_name = preprocess_asset_display_name(kind)
|
||||
saved_count = 0
|
||||
for saved in saved_sets:
|
||||
for trace in session.traces_for_radar_key(saved.radar_key):
|
||||
combo_label = f"input={trace.combo.input} output={trace.combo.output}"
|
||||
try:
|
||||
png_path = self._store.preview_png_dir(kind, set_name, saved.radar_key) / (
|
||||
f"i{trace.combo.input}_o{trace.combo.output}.png"
|
||||
)
|
||||
export_trace_png(
|
||||
trace,
|
||||
png_path,
|
||||
channel=channel,
|
||||
title=f"{display_name} | {saved.display_name} | {combo_label}",
|
||||
)
|
||||
saved_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(
|
||||
f"Failed to save preview PNG for {saved.display_name} ({combo_label}): {exc}"
|
||||
)
|
||||
if saved_count and saved_sets:
|
||||
png_root = self._store.preview_png_dir(kind, set_name, saved_sets[0].radar_key).parent
|
||||
self._log(
|
||||
f"Saved {saved_count} preview PNG(s) for {len(saved_sets)} radar config(s) under {png_root}"
|
||||
)
|
||||
|
||||
def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None:
|
||||
"""Abort active capture session and optionally resume pipeline."""
|
||||
if self._capture_session is None:
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Record the next N runtime measurements to disk, in chunks, on a background thread.
|
||||
|
||||
The manual "Save Dataset" button persists what is *already* in history (capped at the
|
||||
GUI history limit). This mixin adds the complementary "Start + Record" action: arm a
|
||||
recording, then stream the measurements that arrive *after* arming — up to the
|
||||
configured count — to disk without ever blocking acquisition.
|
||||
|
||||
Key properties:
|
||||
|
||||
* **Flat memory.** Measurements are flushed in chunks of :data:`_RECORDING_CHUNK_SIZE`
|
||||
and freed, and at most :data:`_RECORDING_QUEUE_CHUNKS` chunks are ever in flight, so
|
||||
RAM does not grow with the recording length.
|
||||
* **Off the GUI thread.** A single background writer thread does all disk I/O; the GUI
|
||||
poll tick only hands it chunks. The writes never stall acquisition or the web UI. If
|
||||
the disk genuinely cannot keep up, the bounded queue applies brief backpressure
|
||||
rather than growing memory without limit.
|
||||
* **Aligned at capture.** Each result is paired with its raw/preprocessed collection by
|
||||
``collection_id`` as it arrives, so no cross-stage buffering is needed.
|
||||
* **Only new sweeps.** Collections produced before the arm instant are ignored (gated
|
||||
on ``monotonic_ns``), so a pre-existing ring backlog is not recorded.
|
||||
* **No double-arm.** Arming is refused while a recording is still in progress.
|
||||
|
||||
The on-disk layout is identical to "Save Dataset" (one streaming dataset directory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Flush to disk every this many results, then drop them from memory.
|
||||
_RECORDING_CHUNK_SIZE = 200
|
||||
# Max chunks queued for the writer thread before the producer applies backpressure;
|
||||
# bounds in-flight memory to roughly this many chunks of measurements.
|
||||
_RECORDING_QUEUE_CHUNKS = 8
|
||||
# Safety bound on raw/preprocessed collections still waiting for their result (normally
|
||||
# a handful). If the processor stalls, the oldest are dropped so the maps stay bounded.
|
||||
_RECORDING_MAX_PENDING = 512
|
||||
|
||||
|
||||
class AppWindowRecordingMixin:
|
||||
"""Stream the next N arriving measurements to a snapshot dataset on a worker thread."""
|
||||
|
||||
def _init_recording_state(self) -> None:
|
||||
"""Initialise the (idle) disk-recording state. Called once from ``__init__``."""
|
||||
self._recording_active = False
|
||||
self._recording_collecting = False
|
||||
self._recording_target = 0
|
||||
self._recording_since_ns = 0
|
||||
self._recording_enqueued = 0
|
||||
self._recording_writer = None
|
||||
self._recording_queue: queue.Queue | None = None
|
||||
self._recording_thread: threading.Thread | None = None
|
||||
self._recording_stop = threading.Event()
|
||||
self._recording_write_error: str | None = None
|
||||
self._recording_pending_raw: dict[int, object] = {}
|
||||
self._recording_pending_pre: dict[int, object] = {}
|
||||
self._recording_chunk_raw: list = []
|
||||
self._recording_chunk_pre: list = []
|
||||
self._recording_chunk_results: list = []
|
||||
|
||||
# -- arming ---------------------------------------------------------------
|
||||
|
||||
def _start_run_with_recording(self) -> None:
|
||||
"""Arm a streamed recording of the next N measurements, starting the run if stopped.
|
||||
|
||||
``N`` comes from the record-count field; the destination is the shared save
|
||||
path/name. Refuses to start a second recording while one is in progress, and
|
||||
reports a name clash up front (before any data is collected).
|
||||
"""
|
||||
if self._recording_active:
|
||||
self._show_error(
|
||||
"A recording is already in progress; wait for it to finish before starting another",
|
||||
details=f"progress={self._recording_collected_count()}/{self._recording_target}",
|
||||
)
|
||||
return
|
||||
|
||||
target = int(self._record_count.value())
|
||||
|
||||
destination = self._snapshot_destination_dir()
|
||||
if destination.exists():
|
||||
self._show_error(
|
||||
"Recording destination already exists; choose a new name or path",
|
||||
details=f"destination={destination}",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
writer = self._store.create_snapshot_stream(
|
||||
Path(self._save_path_input.text().strip()).expanduser(),
|
||||
self._save_name_input.text().strip(),
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
)
|
||||
# Adjacent config profile, mirroring the manual save (fail if it clashes).
|
||||
self._write_gui_profile_to_path(
|
||||
self._snapshot_config_profile_path(writer.directory), allow_overwrite=False
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to start disk recording", exc)
|
||||
return
|
||||
|
||||
self._recording_writer = writer
|
||||
self._recording_target = target
|
||||
self._recording_since_ns = time.monotonic_ns()
|
||||
self._recording_enqueued = 0
|
||||
self._recording_write_error = None
|
||||
self._recording_pending_raw = {}
|
||||
self._recording_pending_pre = {}
|
||||
self._recording_chunk_raw = []
|
||||
self._recording_chunk_pre = []
|
||||
self._recording_chunk_results = []
|
||||
self._recording_queue = queue.Queue(maxsize=_RECORDING_QUEUE_CHUNKS)
|
||||
self._recording_stop = threading.Event()
|
||||
self._recording_thread = threading.Thread(
|
||||
target=self._recording_writer_loop,
|
||||
args=(writer, self._recording_queue, self._recording_stop),
|
||||
name="disk-recorder",
|
||||
daemon=True,
|
||||
)
|
||||
self._recording_thread.start()
|
||||
self._recording_active = True
|
||||
self._recording_collecting = True
|
||||
|
||||
if not self._supervisor.is_running():
|
||||
self._start_run()
|
||||
|
||||
self._log(f"Disk recording armed: streaming the next {target} measurement(s) to {writer.directory}")
|
||||
|
||||
# -- background writer ----------------------------------------------------
|
||||
|
||||
def _recording_writer_loop(
|
||||
self, writer, work_queue: queue.Queue, stop: threading.Event
|
||||
) -> None:
|
||||
"""Write queued chunks to disk until a sentinel, a stop request, or an error.
|
||||
|
||||
Runs on a daemon thread; touches only the writer (file I/O) and logging — never
|
||||
Qt. A write failure is recorded for the GUI thread to surface and ends the loop.
|
||||
"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
chunk = work_queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if chunk is None: # sentinel: target reached, all chunks drained
|
||||
return
|
||||
raw, preprocessed, results = chunk
|
||||
try:
|
||||
writer.append(raw, preprocessed, results)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the GUI thread
|
||||
logger.exception("Disk recording chunk write failed")
|
||||
self._recording_write_error = f"{type(exc).__name__}: {exc}"
|
||||
return
|
||||
|
||||
# -- capture (called from the ring-read hooks on the poll tick) -----------
|
||||
|
||||
def _record_collection(self, stage: str, collection) -> None:
|
||||
"""Stream one arriving collection while recording; hand chunks to the writer thread.
|
||||
|
||||
``stage`` is ``"raw"``, ``"preprocessed"`` or ``"results"``. Raw/preprocessed
|
||||
collections are held by ``collection_id`` until their result arrives; the result
|
||||
pairs them and appends an aligned measurement to the current chunk.
|
||||
"""
|
||||
if not self._recording_collecting:
|
||||
return
|
||||
|
||||
produced_ns = int(getattr(collection, "monotonic_ns", 0) or 0)
|
||||
if produced_ns and produced_ns < self._recording_since_ns:
|
||||
return
|
||||
|
||||
if stage == "raw":
|
||||
self._stash_pending(self._recording_pending_raw, collection)
|
||||
elif stage == "preprocessed":
|
||||
self._stash_pending(self._recording_pending_pre, collection)
|
||||
elif stage == "results":
|
||||
self._record_result(collection)
|
||||
|
||||
@staticmethod
|
||||
def _stash_pending(pending: dict, collection) -> None:
|
||||
"""Hold a raw/preprocessed collection by id until its result arrives (bounded)."""
|
||||
pending[int(collection.collection_id)] = collection
|
||||
while len(pending) > _RECORDING_MAX_PENDING:
|
||||
pending.pop(next(iter(pending))) # drop oldest; its result never came
|
||||
|
||||
def _record_result(self, result) -> None:
|
||||
"""Pair a result with its raw/preprocessed, buffer it, and flush/finish as needed."""
|
||||
if self._recording_collected_count() >= self._recording_target:
|
||||
return # already have the full target accepted
|
||||
|
||||
collection_id = int(result.collection_id)
|
||||
raw = self._recording_pending_raw.pop(collection_id, None)
|
||||
preprocessed = self._recording_pending_pre.pop(collection_id, None)
|
||||
if raw is not None:
|
||||
self._recording_chunk_raw.append(raw)
|
||||
if preprocessed is not None:
|
||||
self._recording_chunk_pre.append(preprocessed)
|
||||
self._recording_chunk_results.append(result)
|
||||
|
||||
if len(self._recording_chunk_results) >= _RECORDING_CHUNK_SIZE:
|
||||
if not self._flush_recording_chunk():
|
||||
return
|
||||
if self._recording_collected_count() >= self._recording_target:
|
||||
if not self._flush_recording_chunk():
|
||||
return
|
||||
self._stop_collecting()
|
||||
|
||||
def _flush_recording_chunk(self) -> bool:
|
||||
"""Hand the buffered chunk to the writer thread. Returns ``False`` if it aborted."""
|
||||
if not self._recording_chunk_results:
|
||||
return True
|
||||
if self._recording_write_error is not None or not self._recording_thread.is_alive():
|
||||
self._abort_recording_after_write_error() # never block on a dead consumer
|
||||
return False
|
||||
chunk = (self._recording_chunk_raw, self._recording_chunk_pre, self._recording_chunk_results)
|
||||
self._recording_enqueued += len(self._recording_chunk_results)
|
||||
self._recording_chunk_raw = []
|
||||
self._recording_chunk_pre = []
|
||||
self._recording_chunk_results = []
|
||||
self._recording_queue.put(chunk) # brief backpressure only if the disk lags
|
||||
return True
|
||||
|
||||
def _stop_collecting(self) -> None:
|
||||
"""Stop accepting measurements and tell the writer to drain and exit."""
|
||||
self._recording_collecting = False
|
||||
with suppress(Exception):
|
||||
self._recording_queue.put_nowait(None) # sentinel after the last chunk
|
||||
|
||||
# -- completion / teardown (GUI thread) -----------------------------------
|
||||
|
||||
def _poll_recording_writer(self) -> None:
|
||||
"""Finalize a recording once the writer thread has drained (or failed).
|
||||
|
||||
Called every GUI poll tick. Cheap no-op while idle or still writing.
|
||||
"""
|
||||
if not self._recording_active:
|
||||
return
|
||||
if self._recording_write_error is not None:
|
||||
self._abort_recording_after_write_error()
|
||||
return
|
||||
if not self._recording_collecting and not self._recording_thread.is_alive():
|
||||
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
|
||||
written = self._recording_enqueued
|
||||
self._reset_recording_state()
|
||||
self._log(f"Disk recording complete: {written} measurement(s) written to {directory}")
|
||||
|
||||
def _finalize_recording_on_stop(self) -> None:
|
||||
"""Flush the partial chunk and finish the recording when the run is stopped.
|
||||
|
||||
Pressing Stop mid-recording writes everything collected so far — including a
|
||||
not-yet-full chunk — to disk and ends the recording, so no buffered measurement
|
||||
is lost. The dataset then holds exactly what was seen before Stop. Called from
|
||||
``_stop_run`` after its ring drains, so the last in-flight measurements are
|
||||
already captured. The writer is joined briefly (bounded, like the stop drains)
|
||||
so completion is deterministic without waiting on a later poll tick.
|
||||
"""
|
||||
if not self._recording_active or not self._recording_collecting:
|
||||
return
|
||||
if not self._flush_recording_chunk():
|
||||
return # writer already failed and was surfaced/reset
|
||||
self._stop_collecting()
|
||||
|
||||
thread = self._recording_thread
|
||||
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
|
||||
if thread is not None:
|
||||
thread.join(timeout=3.0)
|
||||
write_error = self._recording_write_error
|
||||
written = self._recording_enqueued
|
||||
self._reset_recording_state()
|
||||
if write_error is not None:
|
||||
self._show_error(
|
||||
"Disk recording stopped with a write error",
|
||||
details=f"dataset={directory}\nerror={write_error}",
|
||||
)
|
||||
else:
|
||||
self._log(f"Disk recording stopped: {written} measurement(s) written to {directory}")
|
||||
|
||||
def _abort_recording_after_write_error(self) -> None:
|
||||
"""Surface a writer-thread failure on the GUI thread and disarm."""
|
||||
message = self._recording_write_error or "unknown error"
|
||||
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
|
||||
self._reset_recording_state()
|
||||
self._show_error(
|
||||
"Disk recording failed and was stopped",
|
||||
details=f"dataset={directory}\nerror={message}",
|
||||
)
|
||||
|
||||
def _reset_recording_state(self) -> None:
|
||||
"""Disarm recording, stop the writer thread, and release all buffers."""
|
||||
self._recording_stop.set()
|
||||
if self._recording_queue is not None:
|
||||
with suppress(Exception):
|
||||
self._recording_queue.put_nowait(None)
|
||||
self._recording_active = False
|
||||
self._recording_collecting = False
|
||||
self._recording_writer = None
|
||||
self._recording_queue = None
|
||||
self._recording_thread = None
|
||||
self._recording_write_error = None
|
||||
self._recording_enqueued = 0
|
||||
self._recording_pending_raw = {}
|
||||
self._recording_pending_pre = {}
|
||||
self._recording_chunk_raw = []
|
||||
self._recording_chunk_pre = []
|
||||
self._recording_chunk_results = []
|
||||
|
||||
def _shutdown_recording(self) -> None:
|
||||
"""Stop the writer thread on app teardown, briefly joining so it is not orphaned."""
|
||||
thread = self._recording_thread
|
||||
self._recording_stop.set()
|
||||
if self._recording_queue is not None:
|
||||
with suppress(Exception):
|
||||
self._recording_queue.put_nowait(None)
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=2.0)
|
||||
self._reset_recording_state()
|
||||
|
||||
# -- status ---------------------------------------------------------------
|
||||
|
||||
def _recording_collected_count(self) -> int:
|
||||
"""Measurements accepted so far (handed to the writer + still buffered)."""
|
||||
return self._recording_enqueued + len(self._recording_chunk_results)
|
||||
|
||||
def _recording_status(self) -> dict:
|
||||
"""A compact snapshot of recording progress for the status feed / web UI."""
|
||||
return {
|
||||
"active": self._recording_active,
|
||||
"collected": self._recording_collected_count(),
|
||||
"target": self._recording_target,
|
||||
}
|
||||
@@ -39,23 +39,55 @@ class AppWindowSnapshotMixin:
|
||||
return output_dir / "config_profile.json"
|
||||
|
||||
def _save_snapshot(self) -> None:
|
||||
"""Save runtime snapshot in numpy-directory format."""
|
||||
"""Save the last-N runtime measurements as a numpy snapshot (the "Save Dataset" button)."""
|
||||
self._drain_runtime_rings_for_snapshot()
|
||||
|
||||
if not self._raw_history and not self._pre_history and not self._result_history:
|
||||
self._show_error("No runtime data is available for save", details=self._runtime_history_details())
|
||||
return
|
||||
|
||||
self._write_snapshot_dataset(
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
int(self._save_count.value()),
|
||||
)
|
||||
|
||||
def _snapshot_destination_dir(self) -> Path:
|
||||
"""The directory a save/record would create now, from the current path/name fields.
|
||||
|
||||
Used to surface a name clash *before* doing work (manual save and the disk
|
||||
recorder both create this directory and fail if it already exists).
|
||||
"""
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
return self._store.snapshot_directory(
|
||||
output_root,
|
||||
self._save_name_input.text().strip(),
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
)
|
||||
|
||||
def _write_snapshot_dataset(
|
||||
self,
|
||||
raw_history: list,
|
||||
preprocessed_history: list,
|
||||
result_history: list,
|
||||
last_n: int,
|
||||
) -> None:
|
||||
"""Save the given aligned histories as a numpy snapshot + adjacent config profile.
|
||||
|
||||
Shared by the manual "Save Dataset" button (which passes the runtime history)
|
||||
and the on-the-fly disk recorder (which passes the freshly recorded buffers),
|
||||
so both produce byte-identical dataset layouts and identical error reporting.
|
||||
"""
|
||||
try:
|
||||
last_n = int(self._save_count.value())
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
snapshot_name = self._save_name_input.text().strip()
|
||||
snapshot_dir, summary = self._store.save_runtime_snapshot_numpy(
|
||||
output_root,
|
||||
snapshot_name,
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
raw_history,
|
||||
preprocessed_history,
|
||||
result_history,
|
||||
last_n,
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
)
|
||||
|
||||
@@ -18,15 +18,37 @@ from __future__ import annotations
|
||||
import base64
|
||||
import contextlib
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
|
||||
from python_app.gui.controllers.app_window_config.live_processing_mixin import web_apply_field_names
|
||||
from python_app.webui.controller import WebActionError
|
||||
|
||||
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
||||
_DEFAULT_PORT = 8080
|
||||
# Upper bound on how long a browser control call waits for the GUI thread to run and
|
||||
# report the action. Comfortably above a real save/start, but bounded so a wedged GUI
|
||||
# thread surfaces as an error instead of hanging the HTTP worker forever.
|
||||
_WEB_ACTION_TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
class _WebActionCall:
|
||||
"""One synchronous web action: the GUI thread fills the result, the web thread waits.
|
||||
|
||||
The web (uvicorn) thread emits a control signal carrying this object and blocks on
|
||||
:attr:`done`; the GUI thread runs the desktop action, records any surfaced error in
|
||||
:attr:`error`, and sets the event. This turns the fire-and-forget signal bridge into
|
||||
a request/response so failures reach the browser.
|
||||
"""
|
||||
|
||||
__slots__ = ("done", "error")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.done = threading.Event()
|
||||
self.error: str | None = None
|
||||
# Headless has no shown window, so give the offscreen window a usable size for the
|
||||
# grabbed plot. In GUI mode the user's real (shown) window size is used as-is.
|
||||
_HEADLESS_PLOT_SIZE = (1600, 900)
|
||||
@@ -66,14 +88,18 @@ class AppWindowWebController(QObject):
|
||||
place, so the web thread reads a consistent value without locking.
|
||||
"""
|
||||
|
||||
start_requested = pyqtSignal()
|
||||
stop_requested = pyqtSignal()
|
||||
remove_last_requested = pyqtSignal()
|
||||
single_capture_requested = pyqtSignal()
|
||||
capture_requested = pyqtSignal()
|
||||
# Control signals carry a trailing _WebActionCall the GUI slot fills in, so the web
|
||||
# thread can block on the real outcome. apply_settings stays fire-and-forget: it is
|
||||
# validated up front and returns the live schema, not a pass/fail.
|
||||
start_requested = pyqtSignal(object)
|
||||
stop_requested = pyqtSignal(object)
|
||||
remove_last_requested = pyqtSignal(object)
|
||||
single_capture_requested = pyqtSignal(object)
|
||||
capture_requested = pyqtSignal(object)
|
||||
start_recording_requested = pyqtSignal(str, str, int, object)
|
||||
load_config_requested = pyqtSignal(str, object)
|
||||
save_dataset_requested = pyqtSignal(str, str, object)
|
||||
apply_settings_requested = pyqtSignal(dict)
|
||||
load_config_requested = pyqtSignal(str)
|
||||
save_dataset_requested = pyqtSignal(str, str)
|
||||
|
||||
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -109,20 +135,38 @@ class AppWindowWebController(QObject):
|
||||
|
||||
# -- WebController controls (web thread -> Qt main thread) ---------------
|
||||
|
||||
def _dispatch(self, signal, *args) -> None:
|
||||
"""Emit a control signal and block until the GUI thread reports the outcome.
|
||||
|
||||
Runs on the web worker thread (the routes call this via a thread pool, so the
|
||||
event loop is never blocked). Raises :class:`WebActionError` if the desktop
|
||||
action surfaced an error or did not finish within the timeout.
|
||||
"""
|
||||
call = _WebActionCall()
|
||||
signal.emit(*args, call)
|
||||
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
|
||||
raise WebActionError("The desktop did not complete the action in time")
|
||||
if call.error is not None:
|
||||
raise WebActionError(call.error)
|
||||
|
||||
def start(self) -> None:
|
||||
self.start_requested.emit()
|
||||
self._dispatch(self.start_requested)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_requested.emit()
|
||||
self._dispatch(self.stop_requested)
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self.single_capture_requested.emit()
|
||||
self._dispatch(self.single_capture_requested)
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self.capture_requested.emit()
|
||||
self._dispatch(self.capture_requested)
|
||||
|
||||
def remove_last_measurement(self) -> None:
|
||||
self.remove_last_requested.emit()
|
||||
self._dispatch(self.remove_last_requested)
|
||||
|
||||
def start_recording(self, path: str, name: str, count: int) -> None:
|
||||
"""Arm a run + disk recording of the next ``count`` measurements (the desktop button)."""
|
||||
self._dispatch(self.start_recording_requested, path, name, int(count))
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> dict:
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
@@ -135,16 +179,16 @@ class AppWindowWebController(QObject):
|
||||
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
|
||||
|
||||
Validates the name against the directory here — on the web thread — so an invalid
|
||||
or unsafe name fails the HTTP request immediately instead of silently doing nothing
|
||||
on the Qt side; the actual load runs through the queued signal.
|
||||
or unsafe name fails the HTTP request immediately; the load itself then runs
|
||||
synchronously on the Qt side and any load error is surfaced too.
|
||||
"""
|
||||
if _safe_run_config_path(self._run_configs_dir, name) is None:
|
||||
raise ValueError(f"Unknown run config: {name}")
|
||||
self.load_config_requested.emit(name)
|
||||
self._dispatch(self.load_config_requested, name)
|
||||
|
||||
def save_dataset(self, path: str, name: str) -> None:
|
||||
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
|
||||
self.save_dataset_requested.emit(path, name)
|
||||
self._dispatch(self.save_dataset_requested, path, name)
|
||||
|
||||
|
||||
class AppWindowWebMixin:
|
||||
@@ -168,15 +212,40 @@ class AppWindowWebMixin:
|
||||
# The web picker browses this directory; ensure it exists on fresh deploys.
|
||||
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Capture slot for errors a web-triggered action surfaces (None = no web
|
||||
# action in flight). Read default-safe by `_show_error`/`_show_exception`.
|
||||
self._web_action_error_capture: list[str] | None = None
|
||||
|
||||
controller = AppWindowWebController(self._run_configs_dir, parent=self)
|
||||
controller.start_requested.connect(self._start_run)
|
||||
controller.stop_requested.connect(self._stop_run)
|
||||
controller.single_capture_requested.connect(self._start_single_capture)
|
||||
controller.capture_requested.connect(self._capture_tmp_reference)
|
||||
controller.remove_last_requested.connect(self._remove_last_runtime_history)
|
||||
# Each control signal carries a _WebActionCall the wrapper finalizes, so the
|
||||
# browser learns whether the desktop action actually succeeded.
|
||||
controller.start_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._start_run)
|
||||
)
|
||||
controller.stop_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._stop_run)
|
||||
)
|
||||
controller.single_capture_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._start_single_capture)
|
||||
)
|
||||
controller.capture_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._capture_tmp_reference)
|
||||
)
|
||||
controller.remove_last_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._remove_last_runtime_history)
|
||||
)
|
||||
controller.start_recording_requested.connect(
|
||||
lambda path, name, count, call: self._run_web_action(
|
||||
call, self._start_web_recording, path, name, count
|
||||
)
|
||||
)
|
||||
controller.load_config_requested.connect(
|
||||
lambda name, call: self._run_web_action(call, self._load_web_config, name)
|
||||
)
|
||||
controller.save_dataset_requested.connect(
|
||||
lambda path, name, call: self._run_web_action(call, self._save_web_dataset, path, name)
|
||||
)
|
||||
controller.apply_settings_requested.connect(self._apply_web_live_settings)
|
||||
controller.load_config_requested.connect(self._load_web_config)
|
||||
controller.save_dataset_requested.connect(self._save_web_dataset)
|
||||
|
||||
self._web_controller = controller
|
||||
self._web_update_snapshot() # seed snapshots before the first request
|
||||
@@ -190,6 +259,27 @@ class AppWindowWebMixin:
|
||||
self._web_controller = None
|
||||
self._web_server = None
|
||||
|
||||
def _run_web_action(self, call: _WebActionCall, action, *args) -> None:
|
||||
"""Run a web-triggered desktop action on the GUI thread, capturing its outcome.
|
||||
|
||||
Errors the action reports through ``_show_error``/``_show_exception`` are
|
||||
captured into the call (and still logged/shown on the desktop) instead of
|
||||
vanishing from the browser's view. Re-entrancy-safe: a nested action — e.g. a
|
||||
modal error dialog pumping the event loop in GUI mode — saves and restores the
|
||||
capture slot, so each action only sees its own first error.
|
||||
"""
|
||||
previous_capture = self._web_action_error_capture
|
||||
capture: list[str] = []
|
||||
self._web_action_error_capture = capture
|
||||
try:
|
||||
action(*args)
|
||||
call.error = capture[0] if capture else None
|
||||
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
|
||||
call.error = self._exception_summary(exc)
|
||||
finally:
|
||||
self._web_action_error_capture = previous_capture
|
||||
call.done.set()
|
||||
|
||||
def _load_web_config(self, name: str) -> None:
|
||||
"""Load a run config chosen in the browser through the shared desktop load path.
|
||||
|
||||
@@ -216,6 +306,20 @@ class AppWindowWebMixin:
|
||||
self._save_name_input.setText(name)
|
||||
self._save_snapshot()
|
||||
|
||||
def _start_web_recording(self, path: str, name: str, count: int) -> None:
|
||||
"""Arm disk recording from the browser via the same handler as the desktop button.
|
||||
|
||||
The save path/name fields are mirrored exactly like ``_save_web_dataset`` (blank
|
||||
path keeps the configured destination), the record count is applied to the shared
|
||||
spinbox, then the unchanged desktop arming action runs.
|
||||
"""
|
||||
if path.strip():
|
||||
self._save_path_input.setText(path)
|
||||
self._save_name_input.setText(name)
|
||||
if count >= 1:
|
||||
self._record_count.setValue(min(count, self._record_count.maximum()))
|
||||
self._start_run_with_recording()
|
||||
|
||||
def _web_update_snapshot(self) -> None:
|
||||
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
|
||||
|
||||
@@ -237,6 +341,9 @@ class AppWindowWebMixin:
|
||||
# Current save path/name, so the web fields can prefill the desktop values.
|
||||
"save_path": self._save_path_input.text(),
|
||||
"save_name": self._save_name_input.text(),
|
||||
# Default record count + live disk-recording progress for the web UI.
|
||||
"record_count": int(self._record_count.value()),
|
||||
"recording": self._recording_status(),
|
||||
# Per-stage capture counts, identical to the desktop history
|
||||
# label (raw -> preprocessed -> results), mirrored to the browser.
|
||||
"raw_count": len(self._raw_history),
|
||||
|
||||
@@ -37,10 +37,20 @@ def build_data_actions_group(owner) -> QGroupBox:
|
||||
capture_tmp_reference_button = QPushButton("Capture Tmp Reference")
|
||||
capture_tmp_reference_button.clicked.connect(owner._capture_tmp_reference)
|
||||
capture_tmp_reference_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
record_button = QPushButton("Start + Record to Disk")
|
||||
record_button.setToolTip(
|
||||
"Start the run (if stopped) and save the next N measurements to the path/name below."
|
||||
)
|
||||
record_button.clicked.connect(owner._start_run_with_recording)
|
||||
record_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
owner._save_count = QSpinBox()
|
||||
owner._save_count.setMinimum(1)
|
||||
owner._save_count.setMaximum(10_000)
|
||||
owner._save_count.setValue(int(data_defaults.save_count))
|
||||
owner._record_count = QSpinBox()
|
||||
owner._record_count.setMinimum(1)
|
||||
owner._record_count.setMaximum(1_000_000)
|
||||
owner._record_count.setValue(int(data_defaults.record_count))
|
||||
|
||||
button_grid = QGridLayout()
|
||||
button_grid.setHorizontalSpacing(8)
|
||||
@@ -49,6 +59,7 @@ def build_data_actions_group(owner) -> QGroupBox:
|
||||
button_grid.addWidget(save_vna_json_button, 0, 1)
|
||||
button_grid.addWidget(remove_last_button, 1, 0)
|
||||
button_grid.addWidget(capture_tmp_reference_button, 1, 1)
|
||||
button_grid.addWidget(record_button, 2, 0, 1, 2)
|
||||
button_grid.setColumnStretch(0, 1)
|
||||
button_grid.setColumnStretch(1, 1)
|
||||
layout.addLayout(button_grid)
|
||||
@@ -60,6 +71,13 @@ def build_data_actions_group(owner) -> QGroupBox:
|
||||
count_row.addStretch(1)
|
||||
layout.addLayout(count_row)
|
||||
|
||||
record_count_row = QHBoxLayout()
|
||||
record_count_row.setSpacing(8)
|
||||
record_count_row.addWidget(QLabel("Number of Sweeps to Record"))
|
||||
record_count_row.addWidget(owner._record_count)
|
||||
record_count_row.addStretch(1)
|
||||
layout.addLayout(record_count_row)
|
||||
|
||||
path_row = QHBoxLayout()
|
||||
path_row.setSpacing(8)
|
||||
owner._save_path_input = QLineEdit(str(data_defaults.save_path))
|
||||
|
||||
@@ -8,6 +8,7 @@ from PyQt6.QtWidgets import (
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QSizePolicy,
|
||||
@@ -176,10 +177,14 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
|
||||
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.setDecimals(4)
|
||||
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
|
||||
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_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
|
||||
@@ -193,13 +198,16 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._gpr_common_page = _build_processing_mode_page(
|
||||
group,
|
||||
[
|
||||
("Relative permittivity", owner._gpr_relative_permittivity),
|
||||
("Tx geometry", owner._gpr_tx_geometry_input),
|
||||
("Rx geometry", owner._gpr_rx_geometry_input),
|
||||
],
|
||||
split_index=1,
|
||||
)
|
||||
|
||||
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_common_page.layout().addWidget(owner._gpr_geometry_hint)
|
||||
|
||||
owner._gpr_input_positions_input = QLineEdit(str(gpr_live_defaults.input_positions))
|
||||
owner._gpr_input_positions_input.setPlaceholderText("0,1,2")
|
||||
|
||||
@@ -224,11 +232,15 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._gpr_range_comp_power.setSingleStep(0.01)
|
||||
owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power))
|
||||
|
||||
owner._gpr_angle_comp_power = QDoubleSpinBox()
|
||||
owner._gpr_angle_comp_power.setDecimals(3)
|
||||
owner._gpr_angle_comp_power.setRange(0.0, 5.0)
|
||||
owner._gpr_angle_comp_power.setSingleStep(0.01)
|
||||
owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power))
|
||||
owner._gpr_object_min_frac = QDoubleSpinBox()
|
||||
owner._gpr_object_min_frac.setDecimals(2)
|
||||
owner._gpr_object_min_frac.setRange(0.0, 1.0)
|
||||
owner._gpr_object_min_frac.setSingleStep(0.05)
|
||||
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.addItems(["peak", "combined"])
|
||||
@@ -284,6 +296,14 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
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_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.setDecimals(1)
|
||||
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
|
||||
@@ -310,12 +330,6 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
|
||||
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.setDecimals(2)
|
||||
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
|
||||
@@ -357,7 +371,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
("Min depth m", owner._gpr_min_depth_m),
|
||||
("Max depth m", owner._gpr_max_depth_m),
|
||||
("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),
|
||||
("Motion mode", owner._gpr_motion_mode),
|
||||
("Look angle deg", owner._gpr_look_angle_deg),
|
||||
@@ -365,9 +379,9 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._gpr_ignore_socket_speed_enabled,
|
||||
("Speed m/s", owner._gpr_speed_m_s),
|
||||
("Render mode", owner._gpr_render_mode),
|
||||
("Min visible score", owner._gpr_min_visible_score),
|
||||
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
|
||||
("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),
|
||||
("Stop MHz", owner._gpr_stop_freq_mhz),
|
||||
("Imaging plane Y m", owner._gpr_imaging_plane_y_m),
|
||||
@@ -517,6 +531,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._processing_mode_pages,
|
||||
[
|
||||
("Config mode", owner._legacy_gpr_config_mode),
|
||||
("Relative permittivity", owner._gpr_relative_permittivity),
|
||||
("Input positions", owner._legacy_gpr_input_positions_input),
|
||||
("Output positions", owner._legacy_gpr_output_positions_input),
|
||||
("Min depth m", owner._legacy_gpr_min_depth_m),
|
||||
@@ -569,7 +584,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
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_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_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
@@ -583,9 +598,9 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
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_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_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_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Render captured traces (amplitude + phase) to standalone PNG files.
|
||||
|
||||
The preprocess preview pane only shows the *last* radar variant of each combo,
|
||||
so during a multi-config reference capture the operator never sees the other
|
||||
variants. This helper renders the same amplitude/phase plot pair used in the
|
||||
preview into off-screen PNG files, letting us persist a graph for every config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import QRectF
|
||||
from pyqtgraph.exporters import ImageExporter
|
||||
|
||||
from python_app.models.dataset_model import TraceData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches the preview pane styling in preprocess_dialog so saved PNGs and the
|
||||
# on-screen preview look the same.
|
||||
_BACKGROUND = "#101418"
|
||||
_MAGNITUDE_PEN = pg.mkPen("#4cc9f0", width=1.8)
|
||||
_PHASE_PEN = pg.mkPen("#f48c06", width=1.8)
|
||||
_EXPORT_SIZE = (1400, 520)
|
||||
|
||||
|
||||
def export_trace_png(
|
||||
trace: TraceData,
|
||||
output_path: Path,
|
||||
*,
|
||||
channel: str,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""Render one trace's amplitude (dB) and wrapped phase (deg) panes to a PNG.
|
||||
|
||||
Builds an off-screen ``GraphicsLayoutWidget`` (never shown), plots the same
|
||||
curves as the live preview, and exports the scene as a PNG. Requires a
|
||||
running ``QApplication`` (always true inside the GUI).
|
||||
"""
|
||||
samples = trace.s11 if channel == "s11" else trace.s21
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
|
||||
phase_deg = np.degrees(np.angle(samples))
|
||||
|
||||
layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE)
|
||||
layout.setBackground(_BACKGROUND)
|
||||
try:
|
||||
layout.addLabel(title, row=0, col=0, colspan=2)
|
||||
|
||||
magnitude_plot = layout.addPlot(row=1, col=0)
|
||||
magnitude_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
magnitude_plot.plot(trace.frequency_hz, magnitude_db, pen=_MAGNITUDE_PEN)
|
||||
|
||||
phase_plot = layout.addPlot(row=1, col=1)
|
||||
phase_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.plot(trace.frequency_hz, phase_deg, pen=_PHASE_PEN)
|
||||
|
||||
# An off-screen widget never receives a resizeEvent, so its central
|
||||
# GraphicsLayout keeps its small preferred size and both plots would be
|
||||
# squeezed into the left edge of the image. Force the layout geometry to
|
||||
# the target size, doing manually what a resizeEvent normally triggers.
|
||||
layout.ci.setGeometry(QRectF(0, 0, _EXPORT_SIZE[0], _EXPORT_SIZE[1]))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
exporter = ImageExporter(layout.scene())
|
||||
exporter.parameters()["width"] = _EXPORT_SIZE[0]
|
||||
exporter.export(str(output_path))
|
||||
finally:
|
||||
layout.close()
|
||||
layout.deleteLater()
|
||||
@@ -86,12 +86,38 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
||||
"delay_time": variation.delay_time,
|
||||
},
|
||||
)
|
||||
_write_variation_session(variation)
|
||||
return True
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
finally:
|
||||
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:
|
||||
laser = config.radar.laser_control
|
||||
if not laser.port:
|
||||
|
||||
@@ -13,7 +13,17 @@ comparable S21 trace is a fixed three-stage pipeline:
|
||||
f(phase) = freq0 + (phase - phase0) * (freq1 - freq0) / (phase1 - phase0)
|
||||
|
||||
Trigger jitter shifts every sample's absolute phase together, so the measured
|
||||
band floats from sweep to sweep around the fixed calibration.
|
||||
band floats from sweep to sweep around the fixed calibration. ``np.unwrap``
|
||||
anchors each sweep's ramp to ``np.angle(reference[0])`` on the (-pi, pi] branch,
|
||||
so once that float carries the anchor across the +/-pi cut the whole ramp jumps
|
||||
one 2*pi turn (~117 MHz on the rig) even though nothing physical moved. The
|
||||
genuine float is slow (well under pi between consecutive sweeps) while the wrap
|
||||
is a discrete 2*pi step, so the processor *unwraps the anchor across sweeps*:
|
||||
each sweep's anchor is snapped onto the branch nearest the previous accepted
|
||||
sweep (the first sweep onto the calibration ``phase0`` branch). Validated on
|
||||
10 000 live sweeps: 506 branch wraps, yet the largest cross-sweep anchor step
|
||||
stayed at 1.6 rad (< pi), and the correction cut badly-distorted pass-through
|
||||
sweeps from 580 to 11 while leaving clean sweeps untouched.
|
||||
|
||||
2. **Amplitude normalization.** ``S = main / |reference|`` divides out the
|
||||
stimulus amplitude. Only the magnitude is removed; the reference phase is used
|
||||
@@ -47,6 +57,12 @@ _REFERENCE_AMPLITUDE_FLOOR = 1e-9
|
||||
# a sweep yielding fewer usable points is malformed and rejected.
|
||||
_MIN_USABLE_POINTS = 2
|
||||
|
||||
# One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp
|
||||
# to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep
|
||||
# whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep
|
||||
# anchor tracking snaps it back (see ``KamilAdcSweepProcessor._align_phase_branch``).
|
||||
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KamilAdcProcessingParams:
|
||||
@@ -110,13 +126,18 @@ class KamilAdcSweepProcessor:
|
||||
sweep, so all traces this processor emits share one identical frequency axis.
|
||||
"""
|
||||
|
||||
__slots__ = ("_params", "_grid_hz")
|
||||
__slots__ = ("_params", "_grid_hz", "_previous_anchor_rad")
|
||||
|
||||
def __init__(self, params: KamilAdcProcessingParams) -> None:
|
||||
self._params = params
|
||||
self._grid_hz = np.linspace(
|
||||
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
|
||||
)
|
||||
# Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's
|
||||
# first sample, carried across sweeps so a +/-pi anchor wrap can be undone.
|
||||
# ``None`` until the first sweep is accepted; reset by building a new
|
||||
# processor (i.e. on reconfigure).
|
||||
self._previous_anchor_rad: float | None = None
|
||||
|
||||
@property
|
||||
def params(self) -> KamilAdcProcessingParams:
|
||||
@@ -130,12 +151,44 @@ class KamilAdcSweepProcessor:
|
||||
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
|
||||
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
|
||||
|
||||
Returns frequencies in *step order* (not sorted); see the module docstring
|
||||
for the calibration law.
|
||||
Stateless and *uncorrected* (no cross-sweep branch tracking), so it shows
|
||||
the raw per-sweep axis — used by diagnostics. The live path
|
||||
(:meth:`process`) applies the branch correction. Returns frequencies in
|
||||
*step order* (not sorted); see the module docstring for the calibration law.
|
||||
"""
|
||||
phase = np.unwrap(np.angle(np.asarray(reference)))
|
||||
return self._phase_to_frequency(np.unwrap(np.angle(np.asarray(reference))))
|
||||
|
||||
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
|
||||
"""Apply the affine ``phase -> frequency`` calibration law."""
|
||||
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
|
||||
|
||||
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
|
||||
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
|
||||
|
||||
``np.unwrap`` pins the whole ramp to ``phase[0]`` on the (-pi, pi] branch,
|
||||
so a slow physical float that drags the anchor over the +/-pi cut flips the
|
||||
entire sweep by one :data:`_PHASE_BRANCH_PERIOD_RAD`. We snap this sweep's
|
||||
anchor onto the branch nearest the previous accepted sweep's anchor (the
|
||||
first sweep onto the calibration ``phase0``), then shift the whole ramp by
|
||||
the same whole number of turns.
|
||||
|
||||
Returns ``(branch_aligned_phase, anchor_to_commit)``. The caller commits the
|
||||
anchor only once the sweep is accepted, so a rejected/corrupt sweep can
|
||||
never latch the tracker onto a wrong branch. Genuine sub-pi sweep-to-sweep
|
||||
float rounds to zero turns and is preserved untouched.
|
||||
"""
|
||||
anchor = float(phase[0])
|
||||
reference_anchor = (
|
||||
self._previous_anchor_rad
|
||||
if self._previous_anchor_rad is not None
|
||||
else self._params.phase0_rad
|
||||
)
|
||||
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
|
||||
if turns:
|
||||
shift = turns * _PHASE_BRANCH_PERIOD_RAD
|
||||
return phase + shift, anchor + shift
|
||||
return phase, anchor
|
||||
|
||||
def process(self, main: np.ndarray, reference: np.ndarray) -> np.ndarray | None:
|
||||
"""Return the S21 trace resampled onto the fixed grid, or ``None`` to reject.
|
||||
|
||||
@@ -148,8 +201,11 @@ class KamilAdcSweepProcessor:
|
||||
if main.size < _MIN_USABLE_POINTS or main.size != reference.size:
|
||||
return None
|
||||
|
||||
# Frequency axis from the absolute unwrapped reference phase (step order).
|
||||
freqs = self.reference_frequency_axis(reference)
|
||||
# Frequency axis from the absolute unwrapped reference phase (step order),
|
||||
# with a stray +/-pi anchor wrap undone relative to the last accepted sweep.
|
||||
# The aligned anchor is committed only if this sweep is accepted (below).
|
||||
phase, candidate_anchor = self._align_phase_branch(np.unwrap(np.angle(reference)))
|
||||
freqs = self._phase_to_frequency(phase)
|
||||
|
||||
# Amplitude-only normalization; drop points where the reference vanished.
|
||||
reference_amplitude = np.abs(reference)
|
||||
@@ -178,6 +234,11 @@ class KamilAdcSweepProcessor:
|
||||
if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
|
||||
return None
|
||||
|
||||
# The sweep is accepted: commit its branch-aligned anchor so the next
|
||||
# sweep is tracked relative to it (and a wrap is measured against a real,
|
||||
# in-band reference rather than a rejected one).
|
||||
self._previous_anchor_rad = candidate_anchor
|
||||
|
||||
# Linear interpolation on real/imaginary parts. Because the band lies
|
||||
# within [freqs[0], freqs[-1]], np.interp never extrapolates here.
|
||||
real = np.interp(self._grid_hz, freqs, s21.real)
|
||||
|
||||
@@ -29,6 +29,12 @@ import numpy as np
|
||||
FRAME_BYTES = 8
|
||||
MAIN_MARKER = 0x000A
|
||||
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
|
||||
|
||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||
@@ -52,6 +58,8 @@ class RawSweep:
|
||||
steps: np.ndarray
|
||||
main: np.ndarray
|
||||
reference: np.ndarray
|
||||
combo: tuple[int, int] | None = None
|
||||
dirty: bool = False
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
@@ -66,13 +74,17 @@ class KamilAdcStreamParser:
|
||||
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:
|
||||
self._buffer = bytearray()
|
||||
self._aligned = False
|
||||
self._main: 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]:
|
||||
"""Append ``data`` and return any sweeps completed by it."""
|
||||
@@ -95,6 +107,10 @@ class KamilAdcStreamParser:
|
||||
self._main[step] = complex(real, imag)
|
||||
elif marker == REFERENCE_MARKER:
|
||||
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:
|
||||
raise ValueError(
|
||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||
@@ -107,6 +123,8 @@ class KamilAdcStreamParser:
|
||||
self._aligned = False
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
|
||||
def _align(self) -> bool:
|
||||
"""Discard pre-roll up to and including the first sweep boundary.
|
||||
@@ -122,6 +140,8 @@ class KamilAdcStreamParser:
|
||||
del self._buffer[: index + FRAME_BYTES]
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
self._aligned = True
|
||||
return True
|
||||
|
||||
@@ -130,12 +150,21 @@ class KamilAdcStreamParser:
|
||||
shared = sorted(self._main.keys() & self._reference.keys())
|
||||
main = self._main
|
||||
reference = self._reference
|
||||
combo = self._pending_combo
|
||||
dirty = self._pending_dirty
|
||||
self._main = {}
|
||||
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:
|
||||
return None
|
||||
return RawSweep(
|
||||
steps=np.asarray(shared, dtype=np.int32),
|
||||
main=np.asarray([main[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
|
||||
# the configured stop_timeout_s so a stop can never hang.
|
||||
_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)
|
||||
@@ -54,6 +58,10 @@ class KamilAdcService:
|
||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
||||
|
||||
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)
|
||||
_reader: KamilAdcTtyReader | 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
|
||||
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
|
||||
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:
|
||||
"""Launch the collector and start the TTY reader thread.
|
||||
@@ -122,12 +139,17 @@ class KamilAdcService:
|
||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
||||
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.
|
||||
|
||||
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
|
||||
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:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
@@ -147,7 +169,10 @@ class KamilAdcService:
|
||||
raise TimeoutError(
|
||||
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
||||
)
|
||||
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
|
||||
if combo is None:
|
||||
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)
|
||||
if s21 is not None:
|
||||
return SweepResult(
|
||||
@@ -159,6 +184,31 @@ class KamilAdcService:
|
||||
)
|
||||
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:
|
||||
"""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)
|
||||
_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 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)
|
||||
_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._stop_event.clear()
|
||||
self._latest_sweep = None
|
||||
self._combo_slots = {}
|
||||
self._reader_error = None
|
||||
self._published_count = 0
|
||||
self._thread = threading.Thread(
|
||||
@@ -89,6 +94,7 @@ class KamilAdcTtyReader:
|
||||
finally:
|
||||
self._fd = None
|
||||
self._latest_sweep = None
|
||||
self._combo_slots = {}
|
||||
self._reader_error = None
|
||||
|
||||
@property
|
||||
@@ -131,6 +137,39 @@ class KamilAdcTtyReader:
|
||||
)
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -177,11 +216,22 @@ class KamilAdcTtyReader:
|
||||
return chunk
|
||||
|
||||
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:
|
||||
self._latest_sweep = sweep
|
||||
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:
|
||||
"""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
|
||||
@@ -276,10 +276,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
gui.processing.gpr.range_comp_power,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
angle_comp_power=_optional_float(
|
||||
object_min_frac=_optional_float(
|
||||
gpr_object,
|
||||
"angle_comp_power",
|
||||
gui.processing.gpr.angle_comp_power,
|
||||
"object_min_frac",
|
||||
gui.processing.gpr.object_min_frac,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
score_mode=_optional_string(
|
||||
@@ -330,6 +330,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
gui.processing.gpr.draw_top_m_objects,
|
||||
"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(
|
||||
gpr_object,
|
||||
"start_freq_mhz",
|
||||
@@ -372,12 +378,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
gui.processing.gpr.render_mode,
|
||||
"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(
|
||||
gpr_object,
|
||||
"visible_x_min_m",
|
||||
@@ -467,14 +467,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
)
|
||||
if gui.processing.gpr.range_comp_power < 0.0:
|
||||
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
|
||||
if gui.processing.gpr.angle_comp_power < 0.0:
|
||||
raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0")
|
||||
if gui.processing.gpr.min_visible_score < 0.0:
|
||||
raise ValueError("gui.processing.gpr.min_visible_score must be >= 0")
|
||||
if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0:
|
||||
raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]")
|
||||
if gui.processing.gpr.max_detected_objects_to_draw < 0:
|
||||
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
|
||||
if gui.processing.gpr.draw_top_m_objects < 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:
|
||||
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
|
||||
if gui.processing.legacy_gpr.snr_thresh < 0.0:
|
||||
@@ -504,6 +504,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
gui.data_actions.save_name,
|
||||
"gui.data_actions",
|
||||
),
|
||||
record_count=_optional_int(
|
||||
data_actions_object,
|
||||
"record_count",
|
||||
gui.data_actions.record_count,
|
||||
"gui.data_actions",
|
||||
),
|
||||
)
|
||||
|
||||
preprocess_dialog_object = _as_dict(gui_object.get("preprocess_dialog"), "gui.preprocess_dialog")
|
||||
@@ -580,7 +586,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"min_depth_m": gui.processing.gpr.min_depth_m,
|
||||
"max_depth_m": gui.processing.gpr.max_depth_m,
|
||||
"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,
|
||||
"motion_mode": gui.processing.gpr.motion_mode,
|
||||
"look_angle_deg": gui.processing.gpr.look_angle_deg,
|
||||
@@ -589,6 +595,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
|
||||
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
|
||||
"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,
|
||||
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
|
||||
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
||||
@@ -596,7 +603,6 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
|
||||
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
|
||||
"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_max_m": gui.processing.gpr.visible_x_max_m,
|
||||
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
|
||||
@@ -632,6 +638,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"save_count": gui.data_actions.save_count,
|
||||
"save_path": gui.data_actions.save_path,
|
||||
"save_name": gui.data_actions.save_name,
|
||||
"record_count": gui.data_actions.record_count,
|
||||
},
|
||||
"preprocess_dialog": {
|
||||
"set_name": gui.preprocess_dialog.set_name,
|
||||
|
||||
@@ -59,7 +59,10 @@ class GuiGprStateModel:
|
||||
min_depth_m: float = 2.0
|
||||
max_depth_m: float = 14.0
|
||||
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"
|
||||
motion_mode: str = "int_minus"
|
||||
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
|
||||
@@ -71,6 +74,9 @@ class GuiGprStateModel:
|
||||
ignore_socket_speed_enabled: bool = False
|
||||
max_detected_objects_to_draw: int = 5
|
||||
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
|
||||
stop_freq_mhz: float = 6000.0
|
||||
background_subtract_enabled: bool = True
|
||||
@@ -78,7 +84,6 @@ class GuiGprStateModel:
|
||||
remove_sidelobe_objects_enabled: bool = True
|
||||
imaging_plane_y_m: float = 0.0
|
||||
render_mode: str = "heatmap"
|
||||
min_visible_score: float = 0.0
|
||||
visible_x_min_m: float = -2.0
|
||||
visible_x_max_m: float = 2.0
|
||||
visible_z_min_m: float = 0.0
|
||||
@@ -132,6 +137,9 @@ class GuiDataActionsStateModel:
|
||||
save_count: int = 10
|
||||
save_path: str = ""
|
||||
save_name: str = "snapshot_manual"
|
||||
# How many freshly acquired measurements the "Start + Record" action writes to
|
||||
# disk before it stops recording (acquisition keeps running).
|
||||
record_count: int = 100
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -331,6 +331,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
model.radar.laser_control.variation.delay_time = _read_int(
|
||||
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(port2_payload, model.input_switch)
|
||||
@@ -540,6 +545,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"step": model.radar.laser_control.variation.step,
|
||||
"time_step": model.radar.laser_control.variation.time_step,
|
||||
"delay_time": model.radar.laser_control.variation.delay_time,
|
||||
"temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c,
|
||||
},
|
||||
},
|
||||
"sweep": sweep_payload,
|
||||
|
||||
@@ -116,6 +116,9 @@ class LaserVariationModeModel:
|
||||
step: float = 0.1
|
||||
time_step: int = 20
|
||||
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)
|
||||
|
||||
@@ -32,8 +32,8 @@ class ProcessingLiveConfig:
|
||||
gpr_min_depth_m: float = 2.0
|
||||
gpr_max_depth_m: float = 14.0
|
||||
gpr_range_comp_power: float = 0.1
|
||||
gpr_angle_comp_power: float = 0.0
|
||||
gpr_comp_power: float = 0.2
|
||||
gpr_object_min_frac: float = 0.7
|
||||
gpr_score_mode: str = "combined"
|
||||
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
||||
# correction) or "int_focus" (focusing residual only). Mirrors Python
|
||||
@@ -41,6 +41,7 @@ class ProcessingLiveConfig:
|
||||
gpr_motion_mode: str = "int_minus"
|
||||
gpr_max_detected_objects_to_draw: int = 5
|
||||
gpr_draw_top_m_objects: int = 2
|
||||
gpr_object_approach_min_frames: int = 3
|
||||
gpr_speed_m_s: float = 0.0
|
||||
gpr_look_angle_deg: float = 0.0
|
||||
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
|
||||
@@ -61,8 +62,9 @@ class ProcessingLiveConfig:
|
||||
gpr_background_mean_count: int = 10
|
||||
gpr_remove_sidelobe_objects_enabled: bool = True
|
||||
gpr_imaging_plane_y_m: float = 0.0
|
||||
# Locator filter parameters consumed by the C++ TCP locator server.
|
||||
gpr_min_visible_score: float = 0.0
|
||||
# Locator filter parameter consumed by the C++ TCP locator server. Coherent BP
|
||||
# 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
|
||||
# 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.
|
||||
@@ -109,12 +111,13 @@ class ProcessingLiveConfig:
|
||||
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
||||
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
||||
"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_object_min_frac": float(self.gpr_object_min_frac),
|
||||
"gpr_score_mode": str(self.gpr_score_mode),
|
||||
"gpr_motion_mode": str(self.gpr_motion_mode),
|
||||
"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_object_approach_min_frames": int(self.gpr_object_approach_min_frames),
|
||||
"gpr_speed_m_s": float(self.gpr_speed_m_s),
|
||||
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
|
||||
"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_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
|
||||
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
|
||||
"gpr_min_visible_score": float(self.gpr_min_visible_score),
|
||||
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
||||
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
||||
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
||||
|
||||
@@ -31,7 +31,9 @@ from contextlib import suppress
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
@@ -65,6 +67,60 @@ DO8_FREQ_REF_ARGS = [
|
||||
]
|
||||
COLLECTOR_PATH = "build/bin/kamil_adc_collector"
|
||||
|
||||
# Hardware the daemon / a prior collector may be holding, and how to free it.
|
||||
# Mirrors what start.sh does before an interactive launch (stop the daemon, kill
|
||||
# an orphaned collector) so this tool can grab the L-Card E-502 + lasers too.
|
||||
RADAR_SERVICE_NAME = "radar.service"
|
||||
COLLECTOR_PROCESS_PATTERN = "kamil_adc_collector"
|
||||
# The E-502 is not reacquirable the instant it is released; wait before opening.
|
||||
_DEVICE_SETTLE_SECONDS = 8.0
|
||||
|
||||
|
||||
def _release_radar_hardware(*, settle_seconds: float = _DEVICE_SETTLE_SECONDS) -> None:
|
||||
"""Free the L-Card E-502 + lasers before we open our own collector.
|
||||
|
||||
Mirrors ``start.sh`` on an interactive launch: stop the headless
|
||||
``radar.service`` daemon (if active) so it releases the hardware, then kill any
|
||||
orphaned ``kamil_adc_collector`` a prior (e.g. SSH-killed) run left holding the
|
||||
device. Both steps are best-effort and non-fatal — the collector's own
|
||||
open-retry covers the residual settle time — this just removes the usual reason
|
||||
it never frees up. Passwordless ``sudo systemctl stop radar.service`` is
|
||||
provisioned in ``/etc/sudoers.d/radar``.
|
||||
"""
|
||||
freed = False
|
||||
|
||||
systemctl = shutil.which("systemctl")
|
||||
if systemctl is not None:
|
||||
is_active = subprocess.run(
|
||||
[systemctl, "is-active", "--quiet", RADAR_SERVICE_NAME],
|
||||
check=False,
|
||||
).returncode == 0
|
||||
if is_active:
|
||||
logger.info("Stopping %s so it releases the radar hardware...", RADAR_SERVICE_NAME)
|
||||
stopped = subprocess.run(
|
||||
["sudo", systemctl, "stop", RADAR_SERVICE_NAME], check=False
|
||||
).returncode == 0
|
||||
if stopped:
|
||||
freed = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not stop %s (need passwordless sudo?); continuing anyway",
|
||||
RADAR_SERVICE_NAME,
|
||||
)
|
||||
|
||||
pkill = shutil.which("pkill")
|
||||
if pkill is not None:
|
||||
# pkill returns 0 when it matched & signalled at least one process.
|
||||
if subprocess.run(
|
||||
[pkill, "-9", "-f", COLLECTOR_PROCESS_PATTERN], check=False
|
||||
).returncode == 0:
|
||||
logger.info("Killed orphaned %s process(es) holding the device", COLLECTOR_PROCESS_PATTERN)
|
||||
freed = True
|
||||
|
||||
if freed:
|
||||
logger.info("Waiting %.0fs for the L-Card E-502 to settle...", settle_seconds)
|
||||
time.sleep(settle_seconds)
|
||||
|
||||
|
||||
def _open_with_retry(service: KamilAdcService, *, attempts: int = 4, delay_s: float = 8.0) -> None:
|
||||
"""Open the collector, retrying the transient E-502 device-busy after a close.
|
||||
@@ -519,6 +575,11 @@ def main() -> int:
|
||||
parser.add_argument("--warmup", type=int, default=10, help="Sweeps to discard first (default 10)")
|
||||
parser.add_argument("--apply", action="store_true", help="Write the calibration back to --config")
|
||||
parser.add_argument("--no-laser", action="store_true", help="Skip laser setup (already running)")
|
||||
parser.add_argument(
|
||||
"--no-release",
|
||||
action="store_true",
|
||||
help="Do not stop radar.service / kill orphaned collectors before opening",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diagnose",
|
||||
action="store_true",
|
||||
@@ -541,6 +602,11 @@ def main() -> int:
|
||||
config.radar.kamil_adc.executable_path = COLLECTOR_PATH
|
||||
config.radar.kamil_adc.args = list(DO8_FREQ_REF_ARGS)
|
||||
|
||||
# Free the device the headless daemon / an orphaned collector may be holding,
|
||||
# so opening our own collector does not fail with E-502 device-busy.
|
||||
if not args.no_release:
|
||||
_release_radar_hardware()
|
||||
|
||||
if not args.no_laser:
|
||||
logger.info("Applying laser control...")
|
||||
apply_kamil_adc_laser_control(config)
|
||||
|
||||
@@ -36,8 +36,8 @@ _OPEN_RETRY_LOG_EVERY = 30
|
||||
def _open_radar_with_retry(
|
||||
config: RunConfigModel,
|
||||
radar: KamilAdcService,
|
||||
input_switch: SwitchService,
|
||||
output_switch: SwitchService,
|
||||
input_switch: SwitchService | None,
|
||||
output_switch: SwitchService | None,
|
||||
stop_requested: threading.Event,
|
||||
) -> bool:
|
||||
"""Open+configure the radar and both switches, retrying forever until stop.
|
||||
@@ -48,14 +48,19 @@ def _open_radar_with_retry(
|
||||
relaunched collector starts clean. Returns ``True`` once everything is open, or
|
||||
``False`` if a stop was requested before the device became available. Backoff is
|
||||
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
|
||||
# while still "open", so a mid-run reconnect must close them to force a fresh
|
||||
# collector relaunch and TTY re-attach.
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
if input_switch is not None:
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
if output_switch is not None:
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
with suppress(Exception):
|
||||
radar.close()
|
||||
|
||||
@@ -65,15 +70,19 @@ def _open_radar_with_retry(
|
||||
try:
|
||||
radar.open(stop_event=stop_requested)
|
||||
radar.configure(config.radar.sweep)
|
||||
output_switch.open()
|
||||
input_switch.open()
|
||||
if output_switch is not None:
|
||||
output_switch.open()
|
||||
if input_switch is not None:
|
||||
input_switch.open()
|
||||
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
|
||||
# Drop any partial open (collector process, TTY reader, switches)
|
||||
# before the next attempt so the relaunch starts from a clean state.
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
if input_switch is not None:
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
if output_switch is not None:
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
with suppress(Exception):
|
||||
radar.close()
|
||||
attempt += 1
|
||||
@@ -136,9 +145,26 @@ def main() -> int:
|
||||
"Opened SHM ring writers: raw=%s, raw_tap=%s",
|
||||
config.rings.raw.name, config.rings.raw_tap.name,
|
||||
)
|
||||
radar = KamilAdcService(config)
|
||||
input_switch = SwitchService.from_model(config.input_switch)
|
||||
output_switch = SwitchService.from_model(config.output_switch)
|
||||
# Switch-aware mode: with native switches the collector drives the RF switches
|
||||
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
|
||||
# — 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:
|
||||
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
||||
@@ -155,12 +181,16 @@ def main() -> int:
|
||||
for combo in config.combos:
|
||||
if stop_requested.is_set():
|
||||
break
|
||||
output_switch.switch_to(combo.output)
|
||||
input_switch.switch_to(combo.input)
|
||||
if config.runtime.settling_ms > 0:
|
||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||
|
||||
sweep = radar.acquire()
|
||||
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)
|
||||
input_switch.switch_to(combo.input)
|
||||
if config.runtime.settling_ms > 0:
|
||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||
sweep = radar.acquire()
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input=combo.input, output=combo.output),
|
||||
@@ -222,10 +252,12 @@ def main() -> int:
|
||||
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
||||
collection_id += 1
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
if output_switch is not None:
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
if input_switch is not None:
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
with suppress(Exception):
|
||||
radar.close()
|
||||
raw_tap_writer.close()
|
||||
|
||||
@@ -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,142 @@
|
||||
"""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.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,
|
||||
)
|
||||
controller.connect()
|
||||
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())
|
||||
@@ -153,11 +153,18 @@ def save_result_history_binary(stage_dir: Path, history: list[ResultCollection])
|
||||
)
|
||||
|
||||
|
||||
def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) -> None:
|
||||
"""Write raw/preprocessed collections as NumPy directory tree."""
|
||||
def save_trace_history_numpy(
|
||||
stage_dir: Path, history: list[SweepCollection], *, index_offset: int = 0
|
||||
) -> None:
|
||||
"""Write raw/preprocessed collections as a NumPy directory tree.
|
||||
|
||||
``index_offset`` continues the per-collection directory numbering across calls so a
|
||||
streaming recorder can append successive chunks into the same stage directory
|
||||
without colliding or restarting the index.
|
||||
"""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug("Writing %d NumPy trace collection(s) to %s", len(history), stage_dir)
|
||||
for index, collection in enumerate(history):
|
||||
for index, collection in enumerate(history, start=index_offset):
|
||||
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
||||
collection_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
@@ -197,11 +204,17 @@ def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) ->
|
||||
)
|
||||
|
||||
|
||||
def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection]) -> None:
|
||||
"""Write processed result collections as NumPy directory tree."""
|
||||
def save_result_history_numpy(
|
||||
stage_dir: Path, history: list[ResultCollection], *, index_offset: int = 0
|
||||
) -> None:
|
||||
"""Write processed result collections as a NumPy directory tree.
|
||||
|
||||
``index_offset`` continues the per-collection directory numbering across calls (see
|
||||
:func:`save_trace_history_numpy`) so streamed chunks append cleanly.
|
||||
"""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug("Writing %d NumPy result collection(s) to %s", len(history), stage_dir)
|
||||
for index, collection in enumerate(history):
|
||||
for index, collection in enumerate(history, start=index_offset):
|
||||
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
||||
collection_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
|
||||
@@ -36,6 +36,50 @@ def _compose_snapshot_stem(name: str, name_prefix: str) -> str:
|
||||
return sanitize_path_component(f"{name_prefix}_{base}" if name_prefix else base)
|
||||
|
||||
|
||||
class SnapshotStreamWriter:
|
||||
"""Append aligned raw/preprocessed/result collections to a snapshot dir in chunks.
|
||||
|
||||
A streaming alternative to :meth:`NpzStore.save_runtime_snapshot_numpy` for long
|
||||
recordings: the caller flushes small chunks as measurements arrive and frees them,
|
||||
so memory stays flat instead of buffering every measurement. The on-disk layout is
|
||||
identical (``raw``/``preprocessed``/``results`` subtrees), and per-collection
|
||||
directory indices continue across chunks via a running offset per stage.
|
||||
"""
|
||||
|
||||
__slots__ = ("_dir", "_raw_written", "_preprocessed_written", "_result_written")
|
||||
|
||||
def __init__(self, snapshot_dir: Path) -> None:
|
||||
self._dir = snapshot_dir
|
||||
self._raw_written = 0
|
||||
self._preprocessed_written = 0
|
||||
self._result_written = 0
|
||||
|
||||
@property
|
||||
def directory(self) -> Path:
|
||||
return self._dir
|
||||
|
||||
@property
|
||||
def result_count(self) -> int:
|
||||
"""Number of result collections written to disk so far."""
|
||||
return self._result_written
|
||||
|
||||
def append(
|
||||
self,
|
||||
raw: list[SweepCollection],
|
||||
preprocessed: list[SweepCollection],
|
||||
results: list[ResultCollection],
|
||||
) -> None:
|
||||
"""Write one chunk of (already aligned) collections, continuing each stage's index."""
|
||||
save_trace_history_numpy(self._dir / "raw", raw, index_offset=self._raw_written)
|
||||
save_trace_history_numpy(
|
||||
self._dir / "preprocessed", preprocessed, index_offset=self._preprocessed_written
|
||||
)
|
||||
save_result_history_numpy(self._dir / "results", results, index_offset=self._result_written)
|
||||
self._raw_written += len(raw)
|
||||
self._preprocessed_written += len(preprocessed)
|
||||
self._result_written += len(results)
|
||||
|
||||
|
||||
class NpzStore(StoreApi):
|
||||
"""Persist preprocess sets and runtime snapshots using NumPy files."""
|
||||
|
||||
@@ -194,6 +238,35 @@ class NpzStore(StoreApi):
|
||||
logger.info("Saved binary runtime snapshot (last_n=%d) to %s", last_n, snapshot_dir)
|
||||
return snapshot_dir
|
||||
|
||||
def snapshot_directory(
|
||||
self, output_root_dir: Path, snapshot_name: str, *, name_prefix: str = ""
|
||||
) -> Path:
|
||||
"""Return the directory a snapshot save would create for these inputs.
|
||||
|
||||
Lets callers check ``.exists()`` *before* acquiring data (e.g. the disk
|
||||
recorder arms a run only once the destination is free), so a name clash is
|
||||
reported up front instead of after the measurements are collected. Note a
|
||||
blank ``snapshot_name`` resolves to a fresh timestamp each call, so this is
|
||||
meaningful only for explicit names — exactly the case that can collide.
|
||||
"""
|
||||
return output_root_dir / _compose_snapshot_stem(snapshot_name, name_prefix)
|
||||
|
||||
def create_snapshot_stream(
|
||||
self, output_root_dir: Path, snapshot_name: str, *, name_prefix: str = ""
|
||||
) -> SnapshotStreamWriter:
|
||||
"""Create an empty snapshot directory and return a chunked stream writer for it.
|
||||
|
||||
Raises ``FileExistsError`` if the directory already exists (same guard as
|
||||
:meth:`save_runtime_snapshot_numpy`), so a name clash is reported at arm time.
|
||||
"""
|
||||
snapshot_dir = self.snapshot_directory(output_root_dir, snapshot_name, name_prefix=name_prefix)
|
||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
if snapshot_dir.exists():
|
||||
raise FileExistsError(f"Snapshot directory already exists: {snapshot_dir}")
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=False)
|
||||
logger.info("Opened streaming snapshot directory %s", snapshot_dir)
|
||||
return SnapshotStreamWriter(snapshot_dir)
|
||||
|
||||
def save_runtime_snapshot_numpy(
|
||||
self,
|
||||
output_root_dir: Path,
|
||||
@@ -411,5 +484,15 @@ class NpzStore(StoreApi):
|
||||
"""Return directory for set kind and radar key."""
|
||||
return self._root_dir / kind / radar_key
|
||||
|
||||
def preview_png_dir(self, kind: str, set_name: str, radar_key: str) -> Path:
|
||||
"""Return directory for preview PNGs of one set/radar variant.
|
||||
|
||||
Lives under ``preview_png/`` inside the store so saved graphs sit next to
|
||||
the data they describe, grouped by set name then radar variant. The set
|
||||
name is operator-supplied, so it is sanitized; ``radar_key`` is already
|
||||
filesystem-safe by construction.
|
||||
"""
|
||||
return self._root_dir / "preview_png" / kind / sanitize_path_component(set_name) / radar_key
|
||||
|
||||
|
||||
__all__ = ["NpzStore", "radar_key_from_config"]
|
||||
|
||||
@@ -167,6 +167,55 @@ class SweepProcessorTest(unittest.TestCase):
|
||||
self.assertTrue(np.all(np.isfinite(result.real)))
|
||||
self.assertTrue(np.all(np.isfinite(result.imag)))
|
||||
|
||||
# -- cross-sweep branch tracking ------------------------------------------
|
||||
|
||||
def test_align_phase_branch_anchors_first_sweep_to_calibration(self) -> None:
|
||||
# No previous anchor yet -> snap onto the branch nearest phase0 (=0 here).
|
||||
processor = self._processor()
|
||||
phase = np.array([0.05, 1.0, 2.0]) + 2.0 * np.pi # one turn above phase0
|
||||
aligned, anchor = processor._align_phase_branch(phase)
|
||||
np.testing.assert_allclose(aligned, np.array([0.05, 1.0, 2.0]), atol=1e-9)
|
||||
self.assertAlmostEqual(anchor, 0.05, places=6)
|
||||
|
||||
def test_align_phase_branch_snaps_to_previous_anchor(self) -> None:
|
||||
# A genuine sub-pi float is preserved; a full-turn anchor wrap is undone.
|
||||
processor = self._processor()
|
||||
processor._previous_anchor_rad = 0.05
|
||||
kept, kept_anchor = processor._align_phase_branch(np.array([0.40, 1.4, 2.4]))
|
||||
np.testing.assert_allclose(kept, np.array([0.40, 1.4, 2.4]), atol=1e-9) # <pi: untouched
|
||||
self.assertAlmostEqual(kept_anchor, 0.40, places=6)
|
||||
wrapped, wrapped_anchor = processor._align_phase_branch(np.array([0.05, 1.0, 2.0]) - 2.0 * np.pi)
|
||||
np.testing.assert_allclose(wrapped, np.array([0.05, 1.0, 2.0]), atol=1e-9) # turn undone
|
||||
self.assertAlmostEqual(wrapped_anchor, 0.05, places=6)
|
||||
|
||||
def test_rejected_sweep_does_not_update_branch_tracker(self) -> None:
|
||||
# The anchor is committed only on accepted sweeps, so a rejected sweep
|
||||
# cannot latch the tracker onto a wrong branch.
|
||||
processor = self._processor()
|
||||
covering = _reference(np.linspace(0.0, 100.0, 401))
|
||||
self.assertIsNotNone(processor.process(np.abs(covering).astype(np.complex128), covering))
|
||||
anchor_after_accept = processor._previous_anchor_rad
|
||||
self.assertIsNotNone(anchor_after_accept)
|
||||
short = _reference(np.linspace(0.0, 40.0, 201)) # does not span the band
|
||||
self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), short))
|
||||
self.assertEqual(processor._previous_anchor_rad, anchor_after_accept)
|
||||
|
||||
def test_cross_sweep_unwrap_recovers_continuous_anchor_across_a_wrap(self) -> None:
|
||||
# Two physically adjacent sweeps whose anchor straddles +pi: np.angle wraps
|
||||
# the second's anchor by ~2*pi, but the cross-sweep tracking must recover
|
||||
# the continuous value (~3.3), not the wrapped one (~-2.98).
|
||||
processor = self._processor()
|
||||
ramp_home = np.linspace(3.0, 80.0, 401) # anchor 3.0 (< pi), covers band
|
||||
ramp_drift = np.linspace(3.3, 80.3, 401) # anchor 3.3 (> pi) -> angle wraps
|
||||
ref_home = _reference(ramp_home)
|
||||
ref_drift = _reference(ramp_drift)
|
||||
self.assertIsNotNone(processor.process(np.abs(ref_home).astype(np.complex128), ref_home))
|
||||
self.assertAlmostEqual(processor._previous_anchor_rad, 3.0, places=2)
|
||||
self.assertIsNotNone(processor.process(np.abs(ref_drift).astype(np.complex128), ref_drift))
|
||||
# Without correction this would be ~-2.98 (one turn below); corrected it
|
||||
# continues smoothly from 3.0 to ~3.3.
|
||||
self.assertAlmostEqual(processor._previous_anchor_rad, 3.3, places=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
COMBO_MARKER,
|
||||
MAIN_MARKER,
|
||||
REFERENCE_MARKER,
|
||||
KamilAdcStreamParser,
|
||||
@@ -18,6 +19,10 @@ def _boundary() -> bytes:
|
||||
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:
|
||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||
|
||||
@@ -140,6 +145,40 @@ class KamilAdcStreamParserTest(unittest.TestCase):
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
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:
|
||||
parser = KamilAdcStreamParser()
|
||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
||||
|
||||
@@ -16,7 +16,11 @@ import unittest
|
||||
from unittest import mock
|
||||
|
||||
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.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)
|
||||
|
||||
|
||||
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):
|
||||
"""End-to-end tests over a PTY exercising the background reader thread."""
|
||||
|
||||
@@ -127,6 +135,41 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
finally:
|
||||
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):
|
||||
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"):
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
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()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Tests for the streaming disk-recording mixin.
|
||||
|
||||
The recorder pairs each result with its raw/preprocessed collection by id, buffers a
|
||||
small chunk, and flushes it to a stream writer — so memory stays flat regardless of how
|
||||
many measurements are recorded. These tests pin that behaviour (chunking, finish-at-N,
|
||||
arm guards, only-new gating) against light stubs of the AppWindow collaborators — no Qt,
|
||||
hardware, or disk needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from python_app.gui.controllers import app_window_recording_mixin as rec
|
||||
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin
|
||||
|
||||
|
||||
class _Collection:
|
||||
def __init__(self, collection_id: int, monotonic_ns: int = 0) -> None:
|
||||
self.collection_id = collection_id
|
||||
self.monotonic_ns = monotonic_ns
|
||||
|
||||
|
||||
class _Spin:
|
||||
def __init__(self, value: int) -> None:
|
||||
self._value = value
|
||||
|
||||
def value(self) -> int:
|
||||
return self._value
|
||||
|
||||
|
||||
class _Text:
|
||||
def __init__(self, value: str) -> None:
|
||||
self._value = value
|
||||
|
||||
def text(self) -> str:
|
||||
return self._value
|
||||
|
||||
|
||||
class _Supervisor:
|
||||
def __init__(self, running: bool) -> None:
|
||||
self._running = running
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
|
||||
class _FakePath:
|
||||
def __init__(self, exists: bool) -> None:
|
||||
self._exists = exists
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self._exists
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "/tmp/dataset"
|
||||
|
||||
|
||||
class _FakeWriter:
|
||||
def __init__(self) -> None:
|
||||
self.directory = Path("/tmp/dataset")
|
||||
self.appends: list[tuple[int, int, int]] = []
|
||||
self.result_count = 0
|
||||
|
||||
def append(self, raw, preprocessed, results) -> None:
|
||||
self.appends.append((len(raw), len(preprocessed), len(results)))
|
||||
self.result_count += len(results)
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, *, exists: bool = False) -> None:
|
||||
self._exists = exists
|
||||
self.created: list[tuple] = []
|
||||
self.writer: _FakeWriter | None = None
|
||||
|
||||
def create_snapshot_stream(self, root, name, *, name_prefix=""):
|
||||
if self._exists:
|
||||
raise FileExistsError(f"Snapshot directory already exists: {root}/{name}")
|
||||
self.created.append((root, name, name_prefix))
|
||||
self.writer = _FakeWriter()
|
||||
return self.writer
|
||||
|
||||
|
||||
class _Harness(AppWindowRecordingMixin):
|
||||
def __init__(self, *, count, running, dest_exists=False, store_exists=False) -> None:
|
||||
self._init_recording_state()
|
||||
self._record_count = _Spin(count)
|
||||
self._supervisor = _Supervisor(running)
|
||||
self._store = _FakeStore(exists=store_exists)
|
||||
self._save_path_input = _Text("/tmp")
|
||||
self._save_name_input = _Text("run")
|
||||
self._dest_exists = dest_exists
|
||||
self.started = False
|
||||
self.errors: list[str] = []
|
||||
self.exceptions: list[tuple] = []
|
||||
self.logs: list[str] = []
|
||||
self.profiles: list[tuple] = []
|
||||
|
||||
def _snapshot_destination_dir(self):
|
||||
return _FakePath(self._dest_exists)
|
||||
|
||||
def _radar_config_name_prefix(self) -> str:
|
||||
return "pref"
|
||||
|
||||
def _snapshot_config_profile_path(self, directory):
|
||||
return Path(directory) / "config_profile.json"
|
||||
|
||||
def _write_gui_profile_to_path(self, path, allow_overwrite) -> None:
|
||||
self.profiles.append((path, allow_overwrite))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def _show_error(self, message, *, details=None) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def _show_exception(self, context, exc) -> None:
|
||||
self.exceptions.append((context, exc))
|
||||
|
||||
def _log(self, message) -> None:
|
||||
self.logs.append(message)
|
||||
|
||||
# convenience for tests: stamp collections just after the arm instant so the
|
||||
# "only record sweeps produced after arming" gate accepts them.
|
||||
def feed(self, collection_id: int, *, ns: int | None = None) -> None:
|
||||
if ns is None:
|
||||
ns = self._recording_since_ns + 1
|
||||
self._record_collection("raw", _Collection(collection_id, ns))
|
||||
self._record_collection("preprocessed", _Collection(collection_id, ns))
|
||||
self._record_collection("results", _Collection(collection_id, ns))
|
||||
|
||||
|
||||
class _RecordingTestBase(unittest.TestCase):
|
||||
def _make(self, **kwargs) -> _Harness:
|
||||
harness = _Harness(**kwargs)
|
||||
self.addCleanup(harness._shutdown_recording) # never leak the writer thread
|
||||
return harness
|
||||
|
||||
def _drain_and_finalize(self, harness: _Harness) -> None:
|
||||
"""Wait for the writer thread to flush all chunks, then finalize on the GUI side."""
|
||||
thread = harness._recording_thread
|
||||
self.assertIsNotNone(thread)
|
||||
thread.join(timeout=2.0)
|
||||
self.assertFalse(thread.is_alive(), "writer thread did not drain")
|
||||
harness._poll_recording_writer()
|
||||
|
||||
|
||||
class RecordingArmTest(_RecordingTestBase):
|
||||
def test_arm_starts_a_stopped_run_and_opens_a_stream(self) -> None:
|
||||
h = self._make(count=3, running=False)
|
||||
h._start_run_with_recording()
|
||||
self.assertTrue(h.started)
|
||||
self.assertTrue(h._recording_active)
|
||||
self.assertEqual(h._recording_target, 3)
|
||||
self.assertEqual(len(h._store.created), 1) # one stream opened
|
||||
self.assertEqual(h.profiles[0][1], False) # config profile written, no overwrite
|
||||
|
||||
def test_arm_does_not_restart_a_running_pipeline(self) -> None:
|
||||
h = self._make(count=3, running=True)
|
||||
h._start_run_with_recording()
|
||||
self.assertFalse(h.started)
|
||||
self.assertTrue(h._recording_active)
|
||||
|
||||
def test_arm_refuses_when_destination_exists(self) -> None:
|
||||
h = self._make(count=3, running=True, dest_exists=True)
|
||||
h._start_run_with_recording()
|
||||
self.assertFalse(h._recording_active)
|
||||
self.assertEqual(len(h._store.created), 0)
|
||||
self.assertEqual(len(h.errors), 1)
|
||||
|
||||
def test_second_arm_while_recording_is_refused(self) -> None:
|
||||
h = self._make(count=5, running=True)
|
||||
h._start_run_with_recording()
|
||||
h._start_run_with_recording() # already in progress
|
||||
self.assertEqual(len(h._store.created), 1) # no second stream
|
||||
self.assertEqual(len(h.errors), 1)
|
||||
self.assertTrue(h._recording_active)
|
||||
|
||||
|
||||
class RecordingStreamTest(_RecordingTestBase):
|
||||
def test_ignores_collections_from_before_arming(self) -> None:
|
||||
h = self._make(count=2, running=True)
|
||||
h._start_run_with_recording()
|
||||
arm = h._recording_since_ns
|
||||
h._record_collection("results", _Collection(1, monotonic_ns=arm - 1))
|
||||
self.assertEqual(h._recording_status()["collected"], 0)
|
||||
self.assertIsNotNone(h._store.writer)
|
||||
self.assertEqual(h._store.writer.result_count, 0)
|
||||
|
||||
def test_streams_one_chunk_and_finishes_at_target(self) -> None:
|
||||
h = self._make(count=3, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in (1, 2, 3):
|
||||
h.feed(cid)
|
||||
self._drain_and_finalize(h)
|
||||
self.assertEqual(writer.appends, [(3, 3, 3)]) # one flush at target
|
||||
self.assertEqual(writer.result_count, 3)
|
||||
self.assertFalse(h._recording_active) # disarmed after the writer drained
|
||||
|
||||
def test_flushes_in_chunks_so_memory_stays_flat(self) -> None:
|
||||
with mock.patch.object(rec, "_RECORDING_CHUNK_SIZE", 2):
|
||||
h = self._make(count=5, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in range(1, 6):
|
||||
h.feed(cid)
|
||||
self._drain_and_finalize(h)
|
||||
# 2 + 2 + 1: flushed at the chunk boundaries and the final remainder.
|
||||
self.assertEqual(writer.appends, [(2, 2, 2), (2, 2, 2), (1, 1, 1)])
|
||||
self.assertEqual(writer.result_count, 5)
|
||||
self.assertFalse(h._recording_active)
|
||||
|
||||
def test_does_not_record_beyond_target(self) -> None:
|
||||
h = self._make(count=2, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in (1, 2, 3, 4): # 4 results, target 2
|
||||
h.feed(cid)
|
||||
self._drain_and_finalize(h)
|
||||
self.assertEqual(writer.result_count, 2)
|
||||
|
||||
def test_records_results_with_missing_raw_or_pre(self) -> None:
|
||||
h = self._make(count=1, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
# result with no matching raw/pre buffered (e.g. a dropped raw frame)
|
||||
h._record_collection("results", _Collection(9, monotonic_ns=h._recording_since_ns + 1))
|
||||
self._drain_and_finalize(h)
|
||||
self.assertEqual(writer.appends, [(0, 0, 1)])
|
||||
self.assertEqual(writer.result_count, 1)
|
||||
|
||||
def test_stop_flushes_the_partial_chunk(self) -> None:
|
||||
# Stop pressed before a chunk fills (and before the target): the buffered
|
||||
# measurements must be written, not lost.
|
||||
h = self._make(count=1000, running=True) # target large -> never reached here
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in (1, 2, 3): # 3 < chunk size and < target -> buffered, not yet flushed
|
||||
h.feed(cid)
|
||||
self.assertEqual(writer.appends, []) # nothing flushed yet
|
||||
h._finalize_recording_on_stop() # Stop pressed
|
||||
self.assertEqual(writer.appends, [(3, 3, 3)]) # partial chunk written
|
||||
self.assertEqual(writer.result_count, 3)
|
||||
self.assertFalse(h._recording_active) # recording ended
|
||||
|
||||
def test_stop_when_idle_is_a_noop(self) -> None:
|
||||
h = self._make(count=5, running=True)
|
||||
h._finalize_recording_on_stop() # nothing armed
|
||||
self.assertEqual(len(h._store.created), 0)
|
||||
self.assertFalse(h._recording_active)
|
||||
|
||||
def test_pending_maps_are_bounded(self) -> None:
|
||||
with mock.patch.object(rec, "_RECORDING_MAX_PENDING", 4):
|
||||
h = self._make(count=1000, running=True)
|
||||
h._start_run_with_recording()
|
||||
for cid in range(50): # raws whose results never arrive
|
||||
h._record_collection("raw", _Collection(cid, monotonic_ns=h._recording_since_ns + 1))
|
||||
self.assertLessEqual(len(h._recording_pending_raw), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,6 +34,7 @@ from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
||||
_normalize_channel,
|
||||
build_vna_history_payload,
|
||||
)
|
||||
from python_app.webui.controller import WebActionError # noqa: E402
|
||||
from python_app.webui.streaming import RingBroadcaster # noqa: E402
|
||||
|
||||
|
||||
@@ -160,8 +161,8 @@ class WebControllerTest(unittest.TestCase):
|
||||
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
||||
received: list[dict] = []
|
||||
self.controller.apply_settings_requested.connect(received.append)
|
||||
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
|
||||
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
|
||||
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
|
||||
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
|
||||
self.assertIsInstance(out, list)
|
||||
|
||||
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
||||
@@ -178,21 +179,48 @@ class WebControllerTest(unittest.TestCase):
|
||||
self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame
|
||||
|
||||
def test_controls_emit_signals(self) -> None:
|
||||
# Controls are synchronous now: each emits a call the GUI slot must finalize.
|
||||
# In-thread, emit() runs the slot directly, so finalizing it returns at once.
|
||||
fired: list[str] = []
|
||||
self.controller.start_requested.connect(lambda: fired.append("start"))
|
||||
self.controller.stop_requested.connect(lambda: fired.append("stop"))
|
||||
self.controller.single_capture_requested.connect(lambda: fired.append("single"))
|
||||
self.controller.capture_requested.connect(lambda: fired.append("capture"))
|
||||
|
||||
def handler(label):
|
||||
def slot(call):
|
||||
fired.append(label)
|
||||
call.done.set()
|
||||
return slot
|
||||
|
||||
self.controller.start_requested.connect(handler("start"))
|
||||
self.controller.stop_requested.connect(handler("stop"))
|
||||
self.controller.single_capture_requested.connect(handler("single"))
|
||||
self.controller.capture_requested.connect(handler("capture"))
|
||||
self.controller.start()
|
||||
self.controller.stop()
|
||||
self.controller.single_capture()
|
||||
self.controller.capture_tmp_reference()
|
||||
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
||||
|
||||
def test_action_error_is_raised_to_the_caller(self) -> None:
|
||||
# An error the GUI slot records on the call surfaces as WebActionError (HTTP 400).
|
||||
self.controller.start_requested.connect(
|
||||
lambda call: (setattr(call, "error", "destination already exists"), call.done.set())
|
||||
)
|
||||
with self.assertRaisesRegex(WebActionError, "destination already exists"):
|
||||
self.controller.start()
|
||||
|
||||
def test_start_recording_forwards_path_name_count(self) -> None:
|
||||
received: list[tuple[str, str, int]] = []
|
||||
self.controller.start_recording_requested.connect(
|
||||
lambda p, n, c, call: (received.append((p, n, c)), call.done.set())
|
||||
)
|
||||
self.controller.start_recording("/tmp/out", "run1", 250)
|
||||
self.assertEqual(received, [("/tmp/out", "run1", 250)])
|
||||
|
||||
def test_lists_configs_sorted_and_load_emits_signal(self) -> None:
|
||||
self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"])
|
||||
requested: list[str] = []
|
||||
self.controller.load_config_requested.connect(requested.append)
|
||||
self.controller.load_config_requested.connect(
|
||||
lambda name, call: (requested.append(name), call.done.set())
|
||||
)
|
||||
self.controller.load_config("beta.json")
|
||||
self.assertEqual(requested, ["beta.json"])
|
||||
|
||||
@@ -204,7 +232,9 @@ class WebControllerTest(unittest.TestCase):
|
||||
|
||||
def test_save_dataset_forwards_path_and_name(self) -> None:
|
||||
received: list[tuple[str, str]] = []
|
||||
self.controller.save_dataset_requested.connect(lambda p, n: received.append((p, n)))
|
||||
self.controller.save_dataset_requested.connect(
|
||||
lambda p, n, call: (received.append((p, n)), call.done.set())
|
||||
)
|
||||
self.controller.save_dataset("/tmp/out", "run1")
|
||||
self.assertEqual(received, [("/tmp/out", "run1")])
|
||||
|
||||
|
||||
@@ -13,9 +13,26 @@ from __future__ import annotations
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
class WebActionError(Exception):
|
||||
"""A web-triggered desktop action failed, carrying the operator-facing reason.
|
||||
|
||||
Control methods run the matching desktop action *synchronously* and raise this
|
||||
when that action reports an error (the same message the desktop would show), so
|
||||
the HTTP layer can return it instead of a misleading "ok". Distinct from a plain
|
||||
``ValueError`` (rejected by web-side validation before the action even runs).
|
||||
"""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WebController(Protocol):
|
||||
"""Control + read surface the web layer needs; implemented by the Qt bridge."""
|
||||
"""Control + read surface the web layer needs; implemented by the Qt bridge.
|
||||
|
||||
Control methods are *synchronous*: each runs the corresponding desktop action on
|
||||
the GUI thread and only returns once it has completed, raising
|
||||
:class:`WebActionError` if the action surfaced an error. This is what lets the
|
||||
browser show real failures (e.g. a save into an existing directory) instead of a
|
||||
blind success.
|
||||
"""
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start a continuous run (the desktop "Start" button)."""
|
||||
@@ -26,6 +43,14 @@ class WebController(Protocol):
|
||||
def stop(self) -> None:
|
||||
"""Stop the running pipeline (the desktop "Stop" button)."""
|
||||
|
||||
def start_recording(self, path: str, name: str, count: int) -> None:
|
||||
"""Start a run (if stopped) and record the next ``count`` measurements to disk.
|
||||
|
||||
``path``/``name`` mirror the shared save destination fields (blank ``path``
|
||||
keeps the configured one); ``count`` sets how many measurements are written.
|
||||
Raises :class:`WebActionError` if the destination already exists.
|
||||
"""
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
"""Capture and select a temporary reference (the desktop button)."""
|
||||
|
||||
|
||||
+36
-12
@@ -14,7 +14,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from python_app.webui.controller import WebController
|
||||
from python_app.webui.controller import WebActionError, WebController
|
||||
from python_app.webui.streaming import RingBroadcaster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +26,21 @@ def _controller(request: Request) -> WebController:
|
||||
return request.app.state.controller
|
||||
|
||||
|
||||
async def _run_action(func, *args) -> None:
|
||||
"""Run a (blocking) controller control call off the event loop, mapping failures to 400.
|
||||
|
||||
The control methods run the desktop action synchronously and raise ``ValueError``
|
||||
(rejected input) or ``WebActionError`` (the action itself failed) — both become a
|
||||
400 the browser shows, instead of the old silent "ok". Running in a worker thread
|
||||
keeps the async event loop free while the GUI thread does the work.
|
||||
"""
|
||||
try:
|
||||
await asyncio.to_thread(func, *args)
|
||||
except (ValueError, WebActionError) as exc:
|
||||
logger.warning("Web UI action failed: %s", exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/api/status")
|
||||
async def get_status(request: Request) -> dict:
|
||||
return _controller(request).status()
|
||||
@@ -35,7 +50,7 @@ async def get_status(request: Request) -> dict:
|
||||
async def post_start(request: Request) -> dict:
|
||||
logger.info("Web UI request: start")
|
||||
controller = _controller(request)
|
||||
controller.start()
|
||||
await _run_action(controller.start)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@@ -43,7 +58,7 @@ async def post_start(request: Request) -> dict:
|
||||
async def post_single_capture(request: Request) -> dict:
|
||||
logger.info("Web UI request: single capture")
|
||||
controller = _controller(request)
|
||||
controller.single_capture()
|
||||
await _run_action(controller.single_capture)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@@ -51,7 +66,20 @@ async def post_single_capture(request: Request) -> dict:
|
||||
async def post_stop(request: Request) -> dict:
|
||||
logger.info("Web UI request: stop")
|
||||
controller = _controller(request)
|
||||
controller.stop()
|
||||
await _run_action(controller.stop)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@router.post("/api/start_recording")
|
||||
async def post_start_recording(
|
||||
request: Request,
|
||||
path: str = Body("", embed=True),
|
||||
name: str = Body("", embed=True),
|
||||
count: int = Body(..., embed=True),
|
||||
) -> dict:
|
||||
logger.info("Web UI request: start with disk recording (count=%s)", count)
|
||||
controller = _controller(request)
|
||||
await _run_action(controller.start_recording, path, name, count)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@@ -59,7 +87,7 @@ async def post_stop(request: Request) -> dict:
|
||||
async def post_tmp_reference(request: Request) -> dict:
|
||||
logger.info("Web UI request: capture temporary reference")
|
||||
controller = _controller(request)
|
||||
controller.capture_tmp_reference()
|
||||
await _run_action(controller.capture_tmp_reference)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@@ -67,7 +95,7 @@ async def post_tmp_reference(request: Request) -> dict:
|
||||
async def post_remove_last(request: Request) -> dict:
|
||||
logger.info("Web UI request: remove last measurement")
|
||||
controller = _controller(request)
|
||||
controller.remove_last_measurement()
|
||||
await _run_action(controller.remove_last_measurement)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@@ -80,11 +108,7 @@ async def get_configs(request: Request) -> dict:
|
||||
async def post_load_config(request: Request, name: str = Body(..., embed=True)) -> dict:
|
||||
logger.info("Web UI request: load config %r", name)
|
||||
controller = _controller(request)
|
||||
try:
|
||||
controller.load_config(name)
|
||||
except ValueError as exc:
|
||||
logger.warning("Web UI rejected config load: %s", exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await _run_action(controller.load_config, name)
|
||||
return controller.status()
|
||||
|
||||
|
||||
@@ -96,7 +120,7 @@ async def post_save_dataset(
|
||||
) -> dict:
|
||||
logger.info("Web UI request: save dataset")
|
||||
controller = _controller(request)
|
||||
controller.save_dataset(path, name)
|
||||
await _run_action(controller.save_dataset, path, name)
|
||||
return controller.status()
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ const configActiveEl = document.getElementById("config-active");
|
||||
const savePathInput = document.getElementById("save-path");
|
||||
const saveNameInput = document.getElementById("save-name");
|
||||
const btnSaveDataset = document.getElementById("btn-save-dataset");
|
||||
const recordCountInput = document.getElementById("record-count");
|
||||
const btnStartRecording = document.getElementById("btn-start-recording");
|
||||
const recordingStatusEl = document.getElementById("recording-status");
|
||||
|
||||
const settingsToggle = document.getElementById("settings-toggle");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
@@ -40,6 +43,7 @@ let pendingPng = null; // newest PNG (base64), shown on the next animation
|
||||
let lastFrameTs = 0; // performance.now() of the last received frame
|
||||
let pipelineRunning = false; // gates the config loader (loading requires a stopped pipeline)
|
||||
let saveFieldsPrefilled = false; // seed the save path/name fields once, then leave the operator's edits
|
||||
let recordCountPrefilled = false; // seed the record-count field once from the desktop default
|
||||
|
||||
/* ---- helpers ----------------------------------------------------- */
|
||||
function toast(message, isError) {
|
||||
@@ -169,6 +173,12 @@ function buildSettingsForm(schema) {
|
||||
settingsFields.appendChild(heading);
|
||||
}
|
||||
settingsFields.appendChild(makeFieldRow(entry));
|
||||
if (entry.name === "rx_geometry") {
|
||||
const note = document.createElement("div");
|
||||
note.className = "config-active";
|
||||
note.textContent = "To apply Tx/Rx geometry or permittivity changes: Save Config and restart the app";
|
||||
settingsFields.appendChild(note);
|
||||
}
|
||||
}
|
||||
formSignature = schema.map((e) => e.name).join(",");
|
||||
}
|
||||
@@ -305,14 +315,44 @@ btnSaveDataset.addEventListener("click", async () => {
|
||||
path: savePathInput.value.trim(),
|
||||
name: saveNameInput.value.trim(),
|
||||
});
|
||||
toast("Save requested"); // a radar-config prefix is prepended to the name server-side
|
||||
toast("Dataset saved"); // a radar-config prefix is prepended to the name server-side
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
toast(err.message, true); // e.g. the destination directory already exists
|
||||
} finally {
|
||||
btnSaveDataset.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Start (if stopped) and record the next N measurements to disk, then stop writing.
|
||||
// While a recording is in progress the button stays disabled (driven by status), so a
|
||||
// second recording can't be armed over the first.
|
||||
let recordingActive = false;
|
||||
|
||||
function updateRecordButtonState() {
|
||||
btnStartRecording.disabled = recordingActive;
|
||||
}
|
||||
|
||||
btnStartRecording.addEventListener("click", async () => {
|
||||
const count = parseInt(recordCountInput.value, 10);
|
||||
if (!Number.isFinite(count) || count < 1) {
|
||||
toast("Enter how many sweeps to record (>= 1)", true);
|
||||
return;
|
||||
}
|
||||
btnStartRecording.disabled = true; // optimistic; status keeps it disabled while recording
|
||||
try {
|
||||
await api("/api/start_recording", {
|
||||
path: savePathInput.value.trim(),
|
||||
name: saveNameInput.value.trim(),
|
||||
count,
|
||||
});
|
||||
toast(`Recording armed: next ${count} sweep(s)`);
|
||||
} catch (err) {
|
||||
toast(err.message, true); // e.g. the destination directory already exists
|
||||
} finally {
|
||||
updateRecordButtonState(); // re-enable only if not actually recording
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- status ------------------------------------------------------ */
|
||||
function setStat(el, label, value, cls) {
|
||||
el.className = "stat" + (cls ? " " + cls : "");
|
||||
@@ -333,6 +373,18 @@ function applyStatus(s) {
|
||||
saveNameInput.value = s.save_name || "";
|
||||
saveFieldsPrefilled = true;
|
||||
}
|
||||
if ("record_count" in s && !recordCountPrefilled && document.activeElement !== recordCountInput) {
|
||||
recordCountInput.value = s.record_count; // seed once; don't clobber later edits
|
||||
recordCountPrefilled = true;
|
||||
}
|
||||
if (s.recording) {
|
||||
const r = s.recording;
|
||||
recordingActive = !!r.active;
|
||||
updateRecordButtonState();
|
||||
recordingStatusEl.textContent = r.active
|
||||
? `recording ${r.collected} / ${r.target} sweep(s)…`
|
||||
: "";
|
||||
}
|
||||
if ("processor_running" in s)
|
||||
setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
|
||||
s.processor_running ? "ok" : "off");
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-title">Save dataset</div>
|
||||
<div class="config-title">Save / record dataset</div>
|
||||
<div class="config-row">
|
||||
<input id="save-path" class="config-control" type="text" placeholder="Save path" aria-label="Save path" />
|
||||
</div>
|
||||
@@ -47,6 +47,12 @@
|
||||
<input id="save-name" class="config-control" type="text" placeholder="Name (optional)" aria-label="Save name" />
|
||||
<button id="btn-save-dataset" class="btn">Save</button>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<input id="record-count" class="config-control" type="number" min="1" step="1"
|
||||
placeholder="Sweeps to record" aria-label="Number of sweeps to record" />
|
||||
<button id="btn-start-recording" class="btn">Start + Record</button>
|
||||
</div>
|
||||
<div class="config-active" id="recording-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-head" id="settings-toggle">
|
||||
|
||||
@@ -313,6 +313,15 @@ class MultiRadarSequentialCaptureSession:
|
||||
"""Return completed combo batches in capture order."""
|
||||
return list(self._captured_batches)
|
||||
|
||||
def traces_for_radar_key(self, radar_key: str) -> list[TraceData]:
|
||||
"""Return every captured trace (one per combo) for one radar variant.
|
||||
|
||||
Unlike a batch's ``traces`` (one entry per variant, holding only the
|
||||
preview trace), this returns the full per-combo set, so matrix radars
|
||||
expose all ports rather than just the last one.
|
||||
"""
|
||||
return list(self._traces_by_radar_key.get(radar_key, []))
|
||||
|
||||
def radar_variant_count(self) -> int:
|
||||
"""Return how many radar variants are captured per combo."""
|
||||
return len(self._radar_variants)
|
||||
|
||||
@@ -187,6 +187,16 @@ class SequentialCaptureSession:
|
||||
if self._config.runtime.settling_ms > 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] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
sweep = self._radar.acquire()
|
||||
|
||||
+2
-2
@@ -282,7 +282,7 @@
|
||||
"min_depth_m": 2.0,
|
||||
"max_depth_m": 14.0,
|
||||
"range_comp_power": 0.1,
|
||||
"angle_comp_power": 0.0,
|
||||
"object_min_frac": 0.7,
|
||||
"score_mode": "combined",
|
||||
"motion_mode": "int_minus",
|
||||
"look_angle_deg": 0.0,
|
||||
@@ -291,6 +291,7 @@
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"max_detected_objects_to_draw": 5,
|
||||
"draw_top_m_objects": 2,
|
||||
"object_approach_min_frames": 3,
|
||||
"start_freq_mhz": 3000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"background_subtract_enabled": true,
|
||||
@@ -298,7 +299,6 @@
|
||||
"remove_sidelobe_objects_enabled": false,
|
||||
"imaging_plane_y_m": 0.0,
|
||||
"render_mode": "heatmap",
|
||||
"min_visible_score": 0.0,
|
||||
"visible_x_min_m": -2.0,
|
||||
"visible_x_max_m": 2.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,
|
||||
"max_depth_m": 14.0,
|
||||
"range_comp_power": 0.1,
|
||||
"angle_comp_power": 0.0,
|
||||
"object_min_frac": 0.7,
|
||||
"score_mode": "combined",
|
||||
"motion_mode": "int_minus",
|
||||
"look_angle_deg": 0.0,
|
||||
@@ -291,6 +291,7 @@
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"max_detected_objects_to_draw": 5,
|
||||
"draw_top_m_objects": 2,
|
||||
"object_approach_min_frames": 3,
|
||||
"start_freq_mhz": 3000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"background_subtract_enabled": true,
|
||||
@@ -298,7 +299,6 @@
|
||||
"remove_sidelobe_objects_enabled": false,
|
||||
"imaging_plane_y_m": 0.0,
|
||||
"render_mode": "heatmap",
|
||||
"min_visible_score": 0.0,
|
||||
"visible_x_min_m": -2.0,
|
||||
"visible_x_max_m": 2.0,
|
||||
"visible_z_min_m": 0.0,
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"min_depth_m": 2.0,
|
||||
"max_depth_m": 14.0,
|
||||
"range_comp_power": 0.1,
|
||||
"angle_comp_power": 0.0,
|
||||
"object_min_frac": 0.7,
|
||||
"score_mode": "combined",
|
||||
"motion_mode": "int_minus",
|
||||
"look_angle_deg": 0.0,
|
||||
@@ -291,6 +291,7 @@
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"max_detected_objects_to_draw": 5,
|
||||
"draw_top_m_objects": 2,
|
||||
"object_approach_min_frames": 3,
|
||||
"start_freq_mhz": 3000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"background_subtract_enabled": true,
|
||||
@@ -298,7 +299,6 @@
|
||||
"remove_sidelobe_objects_enabled": false,
|
||||
"imaging_plane_y_m": 0.0,
|
||||
"render_mode": "heatmap",
|
||||
"min_visible_score": 0.0,
|
||||
"visible_x_min_m": -2.0,
|
||||
"visible_x_max_m": 2.0,
|
||||
"visible_z_min_m": 0.0,
|
||||
|
||||
Reference in New Issue
Block a user