Compare commits

...
10 Commits
Author SHA1 Message Date
awe 52c1218bf7 Independent monitor + checker for CHANGE_CURRENT_LD1 variation 2026-07-27 16:24:58 +03:00
Ayzen 3efe968dd1 some kamil_adc fixes 2026-07-02 17:44:24 +03:00
Ayzen 42532c9868 added new filtration and fixed processing parameters 2026-06-23 21:55:40 +03:00
Ayzen 4ca4b27246 new libreVNA config 2026-06-23 19:11:59 +03:00
BogatskiyG f967da7f53 added a warning line in GUI and WEBUI 2026-06-23 18:30:20 +03:00
BogatskiyG 9d30fab534 Merge branch 'some-additions'
export reference png added
2026-06-23 14:01:08 +03:00
BogatskiyG d522828875 some fixes 2026-06-23 13:52:31 +03:00
BogatskiyG 02bbe83446 added reference png export 2026-06-23 12:34:48 +03:00
Ayzen 8db14b9482 added data saving feature 2026-06-23 12:19:31 +03:00
Ayzen 716fd0b07a microfix 2026-06-22 17:33:15 +03:00
64 changed files with 3931 additions and 244 deletions
+4
View File
@@ -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
+31 -8
View File
@@ -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,
@@ -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
@@ -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
View File
@@ -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)
+77
View File
@@ -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)
+30 -1
View File
@@ -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,
)
+54 -4
View File
@@ -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
+22 -15
View File
@@ -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,
+10 -2
View File
@@ -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)
+6
View File
@@ -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,
+3
View File
@@ -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),
+66
View File
@@ -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)
+57 -25
View File
@@ -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()
+78
View File
@@ -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())
+142
View File
@@ -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())
+19 -6
View File
@@ -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)
+83
View File
@@ -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())
+99 -1
View File
@@ -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()
+265
View File
@@ -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()
+38 -8
View File
@@ -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")])
+26 -1
View File
@@ -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
View File
@@ -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()
+54 -2
View File
@@ -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");
+7 -1
View File
@@ -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
View File
@@ -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,
+2 -2
View File
@@ -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,