Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7997abe2d9 | ||
|
|
8a52431bd3 | ||
|
|
cc6d189d52 | ||
|
|
6ada811c2f | ||
|
|
74723bb635 | ||
|
|
e219f6ec02 | ||
|
|
52c1218bf7 | ||
|
|
3efe968dd1 | ||
|
|
42532c9868 | ||
|
|
4ca4b27246 |
+15
-3
@@ -8,6 +8,7 @@ build*
|
|||||||
# C extensions
|
# C extensions
|
||||||
python_app/data*
|
python_app/data*
|
||||||
*.so
|
*.so
|
||||||
|
*.ipynb
|
||||||
*.npy
|
*.npy
|
||||||
snapshots/
|
snapshots/
|
||||||
test_results*
|
test_results*
|
||||||
@@ -20,8 +21,8 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
/lib/
|
||||||
lib64/
|
/lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
@@ -226,5 +227,16 @@ python_app/runtime
|
|||||||
SHARE_INTERNET_TO_PI.md
|
SHARE_INTERNET_TO_PI.md
|
||||||
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs/
|
/docs/
|
||||||
test_end_2/
|
test_end_2/
|
||||||
|
|
||||||
|
# --- device_firmware: PlatformIO / STM32G431 cart remote ---
|
||||||
|
# build output (regenerated by `pio run`)
|
||||||
|
device_firmware/cart_firmware/.pio/
|
||||||
|
# scope captures: raw .bin + .npz + preview .png, local-only
|
||||||
|
device_firmware/cart_firmware/captures/
|
||||||
|
# machine-specific, regenerated by the PlatformIO extension
|
||||||
|
device_firmware/cart_firmware/.vscode/c_cpp_properties.json
|
||||||
|
device_firmware/cart_firmware/.vscode/launch.json
|
||||||
|
device_firmware/cart_firmware/.vscode/ipch/
|
||||||
|
device_firmware/cart_firmware/.vscode/.browse.c_cpp.db*
|
||||||
@@ -65,17 +65,36 @@ PROCESSOR_SOURCES := \
|
|||||||
# from the vendored headers alone and only needs those libraries present at run
|
# from the vendored headers alone and only needs those libraries present at run
|
||||||
# time. Built on demand (not part of `all`) for radar.model == kamil_adc.
|
# time. Built on demand (not part of `all`) for radar.model == kamil_adc.
|
||||||
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
|
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
|
||||||
KAMIL_COLLECTOR_INCLUDES := -I$(KAMIL_COLLECTOR_DIR)/include -I$(KAMIL_COLLECTOR_DIR)/vendor/lcard
|
# The collector now also drives the RF switches itself (switch-aware mode), so it
|
||||||
|
# reuses the orchestrator's switch drivers and the shared run_config loader, which
|
||||||
|
# pull in the common config/ipc/locator headers and the vendored nlohmann/json.
|
||||||
|
KAMIL_COLLECTOR_INCLUDES := \
|
||||||
|
-I$(KAMIL_COLLECTOR_DIR)/include \
|
||||||
|
-I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \
|
||||||
|
-Idata_acq_and_processing/common_cpp/ipc/include \
|
||||||
|
-Idata_acq_and_processing/common_cpp/config/include \
|
||||||
|
-Idata_acq_and_processing/sweep_orchestrator/device_drivers/interfaces \
|
||||||
|
-Idata_acq_and_processing/sweep_orchestrator/device_drivers/switches \
|
||||||
|
-Idata_acq_and_processing/processing/locator/include \
|
||||||
|
-Idata_acq_and_processing/third_party
|
||||||
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
||||||
|
# Collector sources, including the shared drivers/config loader it reuses. These
|
||||||
|
# are compiled into a private build/kamil/ tree (see rule below) so they never
|
||||||
|
# collide with the orchestrator's objects, which use different compile flags.
|
||||||
KAMIL_COLLECTOR_SOURCES := \
|
KAMIL_COLLECTOR_SOURCES := \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
|
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp
|
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
|
||||||
|
data_acq_and_processing/common_cpp/config/src/run_config.cpp \
|
||||||
|
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
|
||||||
|
data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp \
|
||||||
|
data_acq_and_processing/sweep_orchestrator/device_drivers/switches/hmc349a_minimal_driver.cpp
|
||||||
|
|
||||||
SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o))
|
SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o))
|
||||||
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
|
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
|
||||||
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
|
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
|
||||||
KAMIL_COLLECTOR_OBJS := $(addprefix $(BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o))
|
KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil
|
||||||
|
KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(notdir $(KAMIL_COLLECTOR_SOURCES:.cpp=.o)))
|
||||||
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
|
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
|
||||||
|
|
||||||
TARGETS := \
|
TARGETS := \
|
||||||
@@ -101,11 +120,15 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS)
|
|||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS)
|
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS)
|
||||||
|
|
||||||
# The collector is self-contained: compile its objects with only the vendored
|
# The collector compiles into its own build/kamil/ tree with collector-only
|
||||||
# L-Card headers (no project/VISA includes) by overriding the generic rule's
|
# includes (no VISA), so the shared driver/config sources it reuses never collide
|
||||||
# variables for these objects, then link with dlopen/openpty support.
|
# with the orchestrator's objects of the same name. Basenames are unique, so a
|
||||||
$(KAMIL_COLLECTOR_OBJS): INCLUDES := $(KAMIL_COLLECTOR_INCLUDES)
|
# vpath lets one pattern rule find every source.
|
||||||
$(KAMIL_COLLECTOR_OBJS): VISA_CXXFLAGS :=
|
vpath %.cpp $(sort $(dir $(KAMIL_COLLECTOR_SOURCES)))
|
||||||
|
|
||||||
|
$(KAMIL_BUILD_DIR)/%.o: %.cpp
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
$(CXX) $(CXXFLAGS) $(KAMIL_COLLECTOR_INCLUDES) -c $< -o $@
|
||||||
|
|
||||||
$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS)
|
$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS)
|
||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ struct SweepTraceBlock {
|
|||||||
std::vector<Complex32> s11{};
|
std::vector<Complex32> s11{};
|
||||||
// Complex S21 samples for matching frequency points.
|
// Complex S21 samples for matching frequency points.
|
||||||
std::vector<Complex32> s21{};
|
std::vector<Complex32> s21{};
|
||||||
|
// Monotonic window in which THIS trace's sweep was measured, excluding the
|
||||||
|
// switch drive and settling that preceded it. In a switched matrix the
|
||||||
|
// collection is assembled combo by combo over many milliseconds, so the
|
||||||
|
// collection-level window says nothing about when an individual combo was
|
||||||
|
// measured. Zero on both is a valid "unmeasured" sentinel.
|
||||||
|
std::uint64_t capture_start_ns = 0;
|
||||||
|
std::uint64_t capture_end_ns = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RawSweepCollection {
|
struct RawSweepCollection {
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ void require_count_fits(std::uint32_t count, std::size_t min_bytes_each, BinaryR
|
|||||||
return trace;
|
return trace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The capture windows live in a TRAILER after the trace blocks rather than inside
|
||||||
|
// them, so a reader built before they existed still decodes every trace and simply
|
||||||
|
// stops early. The trailer grows the same way: collection window first, then the
|
||||||
|
// per-trace window table (one pair per trace, in trace order).
|
||||||
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
|
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
|
||||||
writer.write(magic);
|
writer.write(magic);
|
||||||
writer.write(collection.collection_id);
|
writer.write(collection.collection_id);
|
||||||
@@ -184,6 +188,12 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
|
|||||||
|
|
||||||
writer.write(collection.capture_start_ns);
|
writer.write(collection.capture_start_ns);
|
||||||
writer.write(collection.capture_end_ns);
|
writer.write(collection.capture_end_ns);
|
||||||
|
|
||||||
|
writer.write(checked_count_to_u32(collection.traces.size(), "Trace capture window count"));
|
||||||
|
for (const auto& trace : collection.traces) {
|
||||||
|
writer.write(trace.capture_start_ns);
|
||||||
|
writer.write(trace.capture_end_ns);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] auto read_trace_collection(BinaryReader& reader, std::uint32_t expected_magic) -> RawSweepCollection {
|
[[nodiscard]] auto read_trace_collection(BinaryReader& reader, std::uint32_t expected_magic) -> RawSweepCollection {
|
||||||
@@ -204,16 +214,31 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
|
|||||||
collection.traces.push_back(read_trace_block(reader));
|
collection.traces.push_back(read_trace_block(reader));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each trailer stage is optional: a payload from an older producer stops after
|
||||||
|
// the trace blocks (or after the collection window) and leaves the rest zeroed.
|
||||||
if (reader.remaining_bytes() == 0U) {
|
if (reader.remaining_bytes() == 0U) {
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
if (reader.remaining_bytes() != (sizeof(std::uint64_t) * 2U)) {
|
if (reader.remaining_bytes() < (sizeof(std::uint64_t) * 2U)) {
|
||||||
throw std::runtime_error("Unexpected trailing bytes in trace collection");
|
throw std::runtime_error("Truncated capture window in trace collection");
|
||||||
}
|
}
|
||||||
|
|
||||||
collection.capture_start_ns = reader.read<std::uint64_t>();
|
collection.capture_start_ns = reader.read<std::uint64_t>();
|
||||||
collection.capture_end_ns = reader.read<std::uint64_t>();
|
collection.capture_end_ns = reader.read<std::uint64_t>();
|
||||||
|
|
||||||
|
if (reader.remaining_bytes() == 0U) {
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto trace_time_count = reader.read<std::uint32_t>();
|
||||||
|
if (trace_time_count != collection.traces.size()) {
|
||||||
|
throw std::runtime_error("Per-trace capture window count does not match trace count");
|
||||||
|
}
|
||||||
|
for (auto& trace : collection.traces) {
|
||||||
|
trace.capture_start_ns = reader.read<std::uint64_t>();
|
||||||
|
trace.capture_end_ns = reader.read<std::uint64_t>();
|
||||||
|
}
|
||||||
|
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ auto ShmRing::open_or_create(
|
|||||||
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
|
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
|
||||||
|
|
||||||
if (magic_ok && version_ok && geometry_ok) {
|
if (magic_ok && version_ok && geometry_ok) {
|
||||||
// Fix #50: the requested mapped_size matched the header geometry, but the
|
// the requested mapped_size matched the header geometry, but the
|
||||||
// backing file may have been created undersized by another process. Confirm
|
// backing file may have been created undersized by another process. Confirm
|
||||||
// st_size covers the geometry before trusting the mapping.
|
// st_size covers the geometry before trusting the mapping.
|
||||||
struct stat info {};
|
struct stat info {};
|
||||||
@@ -257,7 +257,7 @@ auto ShmRing::open_existing(const std::string& name) -> ShmRing {
|
|||||||
if (header->version != kRingVersion) {
|
if (header->version != kRingVersion) {
|
||||||
throw std::runtime_error("Shared memory ring version mismatch for " + name);
|
throw std::runtime_error("Shared memory ring version mismatch for " + name);
|
||||||
}
|
}
|
||||||
// Fix #50: ensure the mapping actually spans every slot the header describes.
|
// ensure the mapping actually spans every slot the header describes.
|
||||||
validate_geometry(*header, mapped_size, name);
|
validate_geometry(*header, mapped_size, name);
|
||||||
|
|
||||||
ShmRing ring{};
|
ShmRing ring{};
|
||||||
@@ -411,7 +411,7 @@ void ShmRing::validate_name(const std::string& name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
|
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
|
||||||
// Fix #50: derive the expected size from the header's own geometry fields and
|
// derive the expected size from the header's own geometry fields and
|
||||||
// require the real mapping to cover it. A bogus capacity/slot_size or a truncated
|
// require the real mapping to cover it. A bogus capacity/slot_size or a truncated
|
||||||
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
|
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
|
||||||
const std::uint32_t capacity = header.capacity;
|
const std::uint32_t capacity = header.capacity;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <memory>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "shared_types.hpp"
|
||||||
|
#include "switch_driver.hpp"
|
||||||
|
|
||||||
|
namespace radar::kamil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Drives the input/output RF switches in lock-step with sweep boundaries.
|
||||||
|
*
|
||||||
|
* The E-502 stream is free-running: sweeps are delimited by DI_SYN2 edges with a
|
||||||
|
* hardware idle gap between them. On every completed sweep we step to the next
|
||||||
|
* switch combination *during that gap*, so the next sweep starts already settled
|
||||||
|
* in the new state and no samples are lost.
|
||||||
|
*
|
||||||
|
* The one failure mode of doing this purely in software is the readout backlog:
|
||||||
|
* if, at the moment we observe a sweep's end, we are running so far behind real
|
||||||
|
* time that the toggle could not have landed within the gap, the upcoming sweep
|
||||||
|
* straddles the transition. We flag that sweep *dirty*; the consumer drops it and
|
||||||
|
* we re-take the same combination on the following sweep (which is then clean,
|
||||||
|
* because no transition happens during its gap). A clean sweep costs nothing; a
|
||||||
|
* miss costs exactly one extra sweep period for that one combination.
|
||||||
|
*
|
||||||
|
* The sequencer owns the two switch drivers and exposes only what the collector
|
||||||
|
* needs at a sweep boundary: which combination the upcoming sweep belongs to and
|
||||||
|
* whether it is expected to be clean. All timing policy lives here so the recv
|
||||||
|
* loop stays readable.
|
||||||
|
*/
|
||||||
|
class SwitchSequencer {
|
||||||
|
public:
|
||||||
|
struct Settings {
|
||||||
|
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||||
|
/// the sequencer entirely (the collector then streams a single passthrough
|
||||||
|
/// channel exactly as before).
|
||||||
|
std::vector<radar::ipc::ComboKey> combos{};
|
||||||
|
/// Guaranteed minimum inter-sweep idle window, in milliseconds. This is a
|
||||||
|
/// hardware property of the sweep generator; treat it as a lower bound.
|
||||||
|
double gap_ms = 10.0;
|
||||||
|
/// RF settle time required after a position change, in milliseconds.
|
||||||
|
double settle_ms = 1.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
SwitchSequencer(
|
||||||
|
Settings settings,
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> input_switch,
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> output_switch
|
||||||
|
)
|
||||||
|
: settings_(std::move(settings)),
|
||||||
|
input_switch_(std::move(input_switch)),
|
||||||
|
output_switch_(std::move(output_switch)) {}
|
||||||
|
|
||||||
|
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||||
|
|
||||||
|
/// Open both switch drivers and park at the first combination.
|
||||||
|
void open() {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output_switch_->open();
|
||||||
|
input_switch_->open();
|
||||||
|
index_ = 0;
|
||||||
|
applied_ = settings_.combos.front();
|
||||||
|
apply(applied_);
|
||||||
|
upcoming_dirty_ = false; // the first sweep is captured in a settled state
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Best-effort: teardown must never throw out of the collector's stop path.
|
||||||
|
try {
|
||||||
|
output_switch_->close();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
input_switch_->close();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combination the in-progress / upcoming sweep is captured under.
|
||||||
|
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||||
|
return settings_.combos[index_];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||||
|
/// transition and must be dropped by the consumer.
|
||||||
|
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Advance the sequence at a completed sweep's end (start of the gap).
|
||||||
|
*
|
||||||
|
* @param backlog_ms Estimated readout lag at this instant — how far behind
|
||||||
|
* real time we are, i.e. how late the toggle we issue now will land.
|
||||||
|
*
|
||||||
|
* If the sweep that just ended was clean we step to the next combination and
|
||||||
|
* toggle the switches now, inside the gap. If it was dirty we re-take the same
|
||||||
|
* combination (the switches are already there and long settled). The upcoming
|
||||||
|
* sweep is marked dirty only when a real transition happens *and* the backlog
|
||||||
|
* leaves no room for it to settle before the next sweep starts.
|
||||||
|
*/
|
||||||
|
void on_sweep_end(double backlog_ms) {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcoming_dirty_) {
|
||||||
|
// The sweep that just ended straddled a transition: re-take the same
|
||||||
|
// combination. The switches are already applied and settled, so the
|
||||||
|
// next sweep is clean without touching the hardware again.
|
||||||
|
upcoming_dirty_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
index_ = (index_ + 1U) % settings_.combos.size();
|
||||||
|
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||||
|
const bool position_changed = !(next == applied_);
|
||||||
|
if (position_changed) {
|
||||||
|
apply(next);
|
||||||
|
applied_ = next;
|
||||||
|
}
|
||||||
|
upcoming_dirty_ = position_changed && ((backlog_ms + settings_.settle_ms) > settings_.gap_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void apply(const radar::ipc::ComboKey& combo) {
|
||||||
|
// Output first, then input — same order the sweep orchestrator uses, so
|
||||||
|
// both acquisition paths drive an identical hardware sequence.
|
||||||
|
output_switch_->switch_to(combo.output_pos);
|
||||||
|
input_switch_->switch_to(combo.input_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings settings_;
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> input_switch_;
|
||||||
|
std::unique_ptr<drivers::SwitchDriver> output_switch_;
|
||||||
|
std::size_t index_ = 0;
|
||||||
|
radar::ipc::ComboKey applied_{};
|
||||||
|
bool upcoming_dirty_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace radar::kamil
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
#include "capture_file_writer.h"
|
#include "capture_file_writer.h"
|
||||||
#include "tty_protocol_writer.h"
|
#include "tty_protocol_writer.h"
|
||||||
|
|
||||||
|
#include "switch_sequencer.h"
|
||||||
|
#include "run_config.hpp"
|
||||||
|
#include "h7992_minimal_driver.hpp"
|
||||||
|
#include "hmc349a_minimal_driver.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
@@ -113,6 +118,12 @@ struct Config {
|
|||||||
std::string live_html_path = "live_plot.html";
|
std::string live_html_path = "live_plot.html";
|
||||||
std::string live_json_path = "live_plot.json";
|
std::string live_json_path = "live_plot.json";
|
||||||
std::optional<std::string> tty_path;
|
std::optional<std::string> tty_path;
|
||||||
|
// When set, the collector reads switch and combo configuration from this
|
||||||
|
// run_config.json and drives the RF switches itself, one combination per
|
||||||
|
// sweep, tagging each emitted sweep with its combo (see SwitchSequencer).
|
||||||
|
std::optional<std::string> run_config_path;
|
||||||
|
double switch_gap_ms = 10.0;
|
||||||
|
double switch_settle_ms = 1.0;
|
||||||
bool di1_group_average = false;
|
bool di1_group_average = false;
|
||||||
bool do1_toggle_per_frame = false;
|
bool do1_toggle_per_frame = false;
|
||||||
bool do1_noise_subtract = false;
|
bool do1_noise_subtract = false;
|
||||||
@@ -479,6 +490,7 @@ void print_help(const char* exe_name) {
|
|||||||
<< " [di1:zero|trace|ignore]\n"
|
<< " [di1:zero|trace|ignore]\n"
|
||||||
<< " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n"
|
<< " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n"
|
||||||
<< " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n"
|
<< " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n"
|
||||||
|
<< " [config:run_config.json] [switch_gap_ms:10] [switch_settle_ms:1]\n"
|
||||||
<< " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n"
|
<< " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n"
|
||||||
<< " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n"
|
<< " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n"
|
||||||
<< " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n"
|
<< " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n"
|
||||||
@@ -514,6 +526,13 @@ void print_help(const char* exe_name) {
|
|||||||
<< " step_words:32768 -> input stream transfer step in 32-bit words\n"
|
<< " step_words:32768 -> input stream transfer step in 32-bit words\n"
|
||||||
<< " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n"
|
<< " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n"
|
||||||
<< " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n"
|
<< " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n"
|
||||||
|
<< " config:run_config.json -> switch-aware mode: read the input/output switch and combo\n"
|
||||||
|
<< " configuration from this run_config.json and drive the RF switches\n"
|
||||||
|
<< " in lock-step with sweeps; each sweep is tagged with its combo via a\n"
|
||||||
|
<< " 0x00C0 frame (input_pos, output_pos, dirty). Omit for standalone mode.\n"
|
||||||
|
<< " switch_gap_ms:10 -> guaranteed minimum inter-sweep idle window; lower bound used to decide\n"
|
||||||
|
<< " whether an in-gap switch lands before the next sweep starts\n"
|
||||||
|
<< " switch_settle_ms:1 -> RF settle time required after a switch position change\n"
|
||||||
<< " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n"
|
<< " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n"
|
||||||
<< " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n"
|
<< " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n"
|
||||||
<< " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n"
|
<< " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n"
|
||||||
@@ -793,6 +812,18 @@ Config parse_args(int argc, char** argv) {
|
|||||||
cfg.tty_path = arg.substr(4);
|
cfg.tty_path = arg.substr(4);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (starts_with(arg, "config:")) {
|
||||||
|
cfg.run_config_path = arg.substr(7);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (starts_with(arg, "switch_gap_ms:")) {
|
||||||
|
cfg.switch_gap_ms = parse_double(arg.substr(14), "switch_gap_ms");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (starts_with(arg, "switch_settle_ms:")) {
|
||||||
|
cfg.switch_settle_ms = parse_double(arg.substr(17), "switch_settle_ms");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
fail("Unknown argument: " + arg);
|
fail("Unknown argument: " + arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,6 +1234,10 @@ constexpr uint32_t kDo1TogglePeriodTicks = 2U;
|
|||||||
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
|
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
|
||||||
constexpr uint32_t kDo8HighTicks = 2U;
|
constexpr uint32_t kDo8HighTicks = 2U;
|
||||||
constexpr uint16_t kTtyMarkerDi8High = 0x00A8U;
|
constexpr uint16_t kTtyMarkerDi8High = 0x00A8U;
|
||||||
|
// Combo tag frame: [0x00C0, input_pos, output_pos, dirty]. Emitted right after a
|
||||||
|
// sweep-boundary frame to tell the consumer which switch combination the upcoming
|
||||||
|
// sweep belongs to, and whether it straddled a switch transition (dirty != 0).
|
||||||
|
constexpr uint16_t kTtyMarkerComboTag = 0x00C0U;
|
||||||
constexpr uint32_t kStreamInputAdcFlag = 0x80000000U;
|
constexpr uint32_t kStreamInputAdcFlag = 0x80000000U;
|
||||||
constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U;
|
constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U;
|
||||||
|
|
||||||
@@ -1802,6 +1837,55 @@ void print_device_info(const t_x502_info& info) {
|
|||||||
<< "MCU firmware: " << info.mcu_firmware_ver << "\n";
|
<< "MCU firmware: " << info.mcu_firmware_ver << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build one switch driver (native GPIO or mock) from a parsed switch config,
|
||||||
|
// reusing the exact driver implementations the sweep orchestrator uses so both
|
||||||
|
// acquisition paths drive identical hardware.
|
||||||
|
std::unique_ptr<radar::drivers::SwitchDriver> make_switch_driver(const radar::config::SwitchConfig& sc) {
|
||||||
|
if (sc.driver_kind == radar::config::SwitchDriverKind::HMC349A) {
|
||||||
|
return std::make_unique<radar::drivers::HMC349AMinimalDriver>(
|
||||||
|
radar::drivers::HMC349AMinimalDriverSettings{
|
||||||
|
.name = sc.name,
|
||||||
|
.mode = sc.driver_mode,
|
||||||
|
.positions = sc.positions,
|
||||||
|
.default_position = sc.default_position,
|
||||||
|
.gpio_chip = sc.gpio_chip,
|
||||||
|
.pin_a = sc.pin_a,
|
||||||
|
.invert_logic = sc.invert_logic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::make_unique<radar::drivers::H7992MinimalDriver>(
|
||||||
|
radar::drivers::H7992MinimalDriverSettings{
|
||||||
|
.name = sc.name,
|
||||||
|
.mode = sc.driver_mode,
|
||||||
|
.positions = sc.positions,
|
||||||
|
.default_position = sc.default_position,
|
||||||
|
.gpio_chip = sc.gpio_chip,
|
||||||
|
.pin_a = sc.pin_a,
|
||||||
|
.pin_b = sc.pin_b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the switch sequencer from the run_config given via `config:<path>`.
|
||||||
|
// Returns nullptr (switching disabled, single-channel passthrough) when no config
|
||||||
|
// was supplied or it declares no combinations.
|
||||||
|
std::unique_ptr<radar::kamil::SwitchSequencer> make_switch_sequencer(const Config& cfg) {
|
||||||
|
if (!cfg.run_config_path.has_value()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const radar::config::RunConfig rc = radar::config::load_run_config(*cfg.run_config_path);
|
||||||
|
if (rc.run_combos.empty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
radar::kamil::SwitchSequencer::Settings settings;
|
||||||
|
settings.combos = rc.run_combos;
|
||||||
|
settings.gap_ms = cfg.switch_gap_ms;
|
||||||
|
settings.settle_ms = cfg.switch_settle_ms;
|
||||||
|
return std::make_unique<radar::kamil::SwitchSequencer>(
|
||||||
|
std::move(settings),
|
||||||
|
make_switch_driver(rc.input_switch),
|
||||||
|
make_switch_driver(rc.output_switch));
|
||||||
|
}
|
||||||
|
|
||||||
int run(const Config& cfg) {
|
int run(const Config& cfg) {
|
||||||
Api api;
|
Api api;
|
||||||
DeviceHandle device(api);
|
DeviceHandle device(api);
|
||||||
@@ -2029,6 +2113,21 @@ int run(const Config& cfg) {
|
|||||||
tty_writer->emit_packet_start(tty_packet_start_marker);
|
tty_writer->emit_packet_start(tty_packet_start_marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Switch sequencer (optional): drives the RF switches in lock-step with sweep
|
||||||
|
// boundaries and tags each sweep with its combination. Disabled (nullptr) when
|
||||||
|
// no run_config was passed, so the standalone/calibration use stays unchanged.
|
||||||
|
std::unique_ptr<radar::kamil::SwitchSequencer> sequencer = make_switch_sequencer(cfg);
|
||||||
|
if (sequencer && sequencer->enabled()) {
|
||||||
|
sequencer->open();
|
||||||
|
std::cout << "Switch sequencer enabled: cycling switch combinations per sweep "
|
||||||
|
<< "(gap_ms=" << cfg.switch_gap_ms << ", settle_ms=" << cfg.switch_settle_ms << ")\n";
|
||||||
|
} else {
|
||||||
|
sequencer.reset(); // normalize to nullptr so call sites can test the pointer
|
||||||
|
}
|
||||||
|
// Estimated readout backlog (ms) at the current loop iteration; how far behind
|
||||||
|
// real time we are, used to decide whether an in-gap switch lands in time.
|
||||||
|
double current_backlog_ms = 0.0;
|
||||||
|
|
||||||
std::unique_ptr<CaptureFileWriter> writer;
|
std::unique_ptr<CaptureFileWriter> writer;
|
||||||
if (!fast_tty_avg_stream_mode) {
|
if (!fast_tty_avg_stream_mode) {
|
||||||
writer = std::make_unique<CaptureFileWriter>(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path);
|
writer = std::make_unique<CaptureFileWriter>(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path);
|
||||||
@@ -2317,6 +2416,15 @@ int run(const Config& cfg) {
|
|||||||
|
|
||||||
auto append_tty_packet_start = [&]() {
|
auto append_tty_packet_start = [&]() {
|
||||||
append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF);
|
append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF);
|
||||||
|
// Right after the boundary, tag the upcoming sweep with its switch combo so
|
||||||
|
// the consumer can group sweeps and drop the dirty ones.
|
||||||
|
if (sequencer) {
|
||||||
|
const radar::ipc::ComboKey combo = sequencer->current_combo();
|
||||||
|
append_tty_frame(kTtyMarkerComboTag,
|
||||||
|
static_cast<uint16_t>(combo.input_pos),
|
||||||
|
static_cast<uint16_t>(combo.output_pos),
|
||||||
|
static_cast<uint16_t>(sequencer->current_dirty() ? 1U : 0U));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
auto append_tty_group_step = [&]() {
|
auto append_tty_group_step = [&]() {
|
||||||
@@ -2714,6 +2822,12 @@ int run(const Config& cfg) {
|
|||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A real sweep just completed: step the switch sequence into the gap. A
|
||||||
|
// user-stop teardown is not a sweep boundary, so skip it.
|
||||||
|
if (sequencer && (frames != 0U) && (reason != PacketCloseReason::UserStop)) {
|
||||||
|
sequencer->on_sweep_end(current_backlog_ms);
|
||||||
|
}
|
||||||
|
|
||||||
packet_active = false;
|
packet_active = false;
|
||||||
packet_avg_steps = 0;
|
packet_avg_steps = 0;
|
||||||
fast_packet_frames = 0;
|
fast_packet_frames = 0;
|
||||||
@@ -2768,6 +2882,11 @@ int run(const Config& cfg) {
|
|||||||
recv_request_words = std::min<uint32_t>(ready_words, read_capacity_words);
|
recv_request_words = std::min<uint32_t>(ready_words, read_capacity_words);
|
||||||
recv_timeout_ms = 0;
|
recv_timeout_ms = 0;
|
||||||
}
|
}
|
||||||
|
// Backlog already queued in the driver = how far behind real time we are.
|
||||||
|
// Drives the switch sequencer's in-gap clean/dirty decision.
|
||||||
|
if ((ready_err == X502_ERR_OK) && (combined_input_rate_hz > 0.0)) {
|
||||||
|
current_backlog_ms = 1000.0 * static_cast<double>(ready_words) / combined_input_rate_hz;
|
||||||
|
}
|
||||||
|
|
||||||
const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms);
|
const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms);
|
||||||
if (recvd < 0) {
|
if (recvd < 0) {
|
||||||
@@ -3170,6 +3289,11 @@ int run(const Config& cfg) {
|
|||||||
|
|
||||||
expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
|
expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
|
||||||
device.streams_started = false;
|
device.streams_started = false;
|
||||||
|
|
||||||
|
// Park the switches in their safe default and release the GPIO lines.
|
||||||
|
if (sequencer) {
|
||||||
|
sequencer->close();
|
||||||
|
}
|
||||||
if (cfg.do1_toggle_per_frame) {
|
if (cfg.do1_toggle_per_frame) {
|
||||||
const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
|
const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
|
||||||
expect_ok(api,
|
expect_ok(api,
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ auto CalibrationMaster::apply_to_trace(const ipc::SweepTraceBlock& measured_trac
|
|||||||
output.frequency_hz = measured_trace.frequency_hz;
|
output.frequency_hz = measured_trace.frequency_hz;
|
||||||
output.s21 = apply_s21(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s21);
|
output.s21 = apply_s21(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s21);
|
||||||
output.s11 = apply_s11(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s11);
|
output.s11 = apply_s11(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s11);
|
||||||
|
// Calibration reshapes the samples, not when they were measured.
|
||||||
|
output.capture_start_ns = measured_trace.capture_start_ns;
|
||||||
|
output.capture_end_ns = measured_trace.capture_end_ns;
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ auto ReferenceMaster::apply_to_trace(const ipc::SweepTraceBlock& calibrated_trac
|
|||||||
output.frequency_hz = calibrated_trace.frequency_hz;
|
output.frequency_hz = calibrated_trace.frequency_hz;
|
||||||
output.s21 = apply_s21(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s21);
|
output.s21 = apply_s21(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s21);
|
||||||
output.s11 = apply_s11(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s11);
|
output.s11 = apply_s11(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s11);
|
||||||
|
// Reference subtraction reshapes the samples, not when they were measured.
|
||||||
|
output.capture_start_ns = calibrated_trace.capture_start_ns;
|
||||||
|
output.capture_end_ns = calibrated_trace.capture_end_ns;
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -45,8 +45,10 @@ struct ProcessingLiveConfig {
|
|||||||
float gpr_min_depth_m = 2.0F;
|
float gpr_min_depth_m = 2.0F;
|
||||||
float gpr_max_depth_m = 14.0F;
|
float gpr_max_depth_m = 14.0F;
|
||||||
float gpr_range_comp_power = 0.1F;
|
float gpr_range_comp_power = 0.1F;
|
||||||
float gpr_angle_comp_power = 0.0F;
|
|
||||||
float gpr_comp_power = 0.2F;
|
float gpr_comp_power = 0.2F;
|
||||||
|
// BP object-detection stop level, as a fraction of the global peak (Python
|
||||||
|
// Horns_motion_3libre.py BP_OBJECT_MIN_FRAC); peaks below it are not objects.
|
||||||
|
float gpr_object_min_frac = 0.7F;
|
||||||
std::string gpr_score_mode = "combined";
|
std::string gpr_score_mode = "combined";
|
||||||
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
||||||
// correction) or "int_focus" (focusing residual only). Mirrors the Python
|
// correction) or "int_focus" (focusing residual only). Mirrors the Python
|
||||||
@@ -73,13 +75,15 @@ struct ProcessingLiveConfig {
|
|||||||
// BP image is computed in the y=imaging_plane_y_m slice of the 3D grid.
|
// BP image is computed in the y=imaging_plane_y_m slice of the 3D grid.
|
||||||
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
|
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
|
||||||
float gpr_imaging_plane_y_m = 0.0F;
|
float gpr_imaging_plane_y_m = 0.0F;
|
||||||
// Locator filter parameters. Mode-dependent threshold (legacy_gpr uses
|
// Coherent BP object visibility (window + the draw limits below) is applied in
|
||||||
// `legacy_gpr_min_visible_pair_count`, everything else uses
|
// the processor itself, matching Horns_motion_3libre.py — there is NO score
|
||||||
// `gpr_min_visible_score`). Draw limits apply only to non-legacy modes.
|
// threshold for it. Only legacy GPR still thresholds, on a pair count.
|
||||||
float gpr_min_visible_score = 0.0F;
|
|
||||||
float legacy_gpr_min_visible_pair_count = 0.0F;
|
float legacy_gpr_min_visible_pair_count = 0.0F;
|
||||||
std::uint32_t gpr_max_detected_objects_to_draw = 0;
|
std::uint32_t gpr_max_detected_objects_to_draw = 0;
|
||||||
std::uint32_t gpr_draw_top_m_objects = 0;
|
std::uint32_t gpr_draw_top_m_objects = 0;
|
||||||
|
// Cross-frame approach filter: an object is shown only once it persists as a
|
||||||
|
// motion-consistent track over this many consecutive frames (<= 1 disables it).
|
||||||
|
std::uint32_t gpr_object_approach_min_frames = 3;
|
||||||
// Visible X/Z window (metres). The locator clips broadcast objects to this
|
// Visible X/Z window (metres). The locator clips broadcast objects to this
|
||||||
// window so the socket emits only what the desktop plot actually shows.
|
// window so the socket emits only what the desktop plot actually shows.
|
||||||
float gpr_visible_x_min_m = -2.0F;
|
float gpr_visible_x_min_m = -2.0F;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
std::uint64_t last_applied_history_command_seq = 0;
|
std::uint64_t last_applied_history_command_seq = 0;
|
||||||
std::uint64_t error_count = 0;
|
std::uint64_t error_count = 0;
|
||||||
std::uint64_t consecutive_errors = 0;
|
std::uint64_t consecutive_errors = 0;
|
||||||
// Fix #55: track the socket-fed speed used for the last reprocess so a change
|
// track the socket-fed speed used for the last reprocess so a change
|
||||||
// arriving without a live-config revision bump still triggers a reprocess of
|
// arriving without a live-config revision bump still triggers a reprocess of
|
||||||
// the current result (gated below by reprocess_current_result).
|
// the current result (gated below by reprocess_current_result).
|
||||||
std::optional<double> last_reprocessed_socket_speed = std::nullopt;
|
std::optional<double> last_reprocessed_socket_speed = std::nullopt;
|
||||||
@@ -124,7 +124,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
publish_locator(replay_result, live_config);
|
publish_locator(replay_result, live_config);
|
||||||
}
|
}
|
||||||
last_replayed_revision = live_revision;
|
last_replayed_revision = live_revision;
|
||||||
// Fix #55: record the speed we just reprocessed with so an
|
//record the speed we just reprocessed with so an
|
||||||
// unchanged socket value does not retrigger every iteration.
|
// unchanged socket value does not retrigger every iteration.
|
||||||
last_reprocessed_socket_speed = current_socket_speed;
|
last_reprocessed_socket_speed = current_socket_speed;
|
||||||
}
|
}
|
||||||
@@ -232,29 +232,23 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config
|
|||||||
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
|
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
|
||||||
|
|
||||||
radar::locator::FilterParams filter{};
|
radar::locator::FilterParams filter{};
|
||||||
// Clip broadcast objects to the same visible X/Z window the desktop plot uses,
|
|
||||||
// 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") {
|
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;
|
filter.min_score = live_config.legacy_gpr_min_visible_pair_count;
|
||||||
// The GUI deliberately disables the "draw top N" capping for legacy
|
|
||||||
// GPR, so we also skip it on the wire to match observation semantics.
|
|
||||||
filter.draw_limits.reset();
|
filter.draw_limits.reset();
|
||||||
} else {
|
|
||||||
filter.min_score = live_config.gpr_min_visible_score;
|
|
||||||
if (live_config.gpr_max_detected_objects_to_draw > 0U
|
|
||||||
&& live_config.gpr_draw_top_m_objects > 0U) {
|
|
||||||
filter.draw_limits = radar::locator::DrawLimits{
|
|
||||||
.max_detected_objects = live_config.gpr_max_detected_objects_to_draw,
|
|
||||||
.draw_top_objects = live_config.gpr_draw_top_m_objects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Coherent BP already emits the FINAL visible object set from the processor
|
||||||
|
// (window + N/M, no score threshold — matching Horns_motion_3libre.py), so the
|
||||||
|
// socket forwards it verbatim. The default FilterParams passes everything through
|
||||||
|
// (it only drops non-finite rows), keeping the filtering logic in one place.
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,11 +255,11 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
|||||||
}
|
}
|
||||||
config.gpr_range_comp_power = static_cast<float>(found->get<double>());
|
config.gpr_range_comp_power = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
if (const auto found = root.find("gpr_angle_comp_power"); found != root.end()) {
|
if (const auto found = root.find("gpr_object_min_frac"); found != root.end()) {
|
||||||
if (!found->is_number()) {
|
if (!found->is_number()) {
|
||||||
throw std::runtime_error("processing.gpr_angle_comp_power must be number");
|
throw std::runtime_error("processing.gpr_object_min_frac must be number");
|
||||||
}
|
}
|
||||||
config.gpr_angle_comp_power = static_cast<float>(found->get<double>());
|
config.gpr_object_min_frac = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
if (const auto found = root.find("gpr_comp_power"); found != root.end()) {
|
if (const auto found = root.find("gpr_comp_power"); found != root.end()) {
|
||||||
if (!found->is_number()) {
|
if (!found->is_number()) {
|
||||||
@@ -355,12 +355,6 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
|||||||
}
|
}
|
||||||
config.gpr_imaging_plane_y_m = static_cast<float>(found->get<double>());
|
config.gpr_imaging_plane_y_m = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
if (const auto found = root.find("gpr_min_visible_score"); found != root.end()) {
|
|
||||||
if (!found->is_number()) {
|
|
||||||
throw std::runtime_error("processing.gpr_min_visible_score must be number");
|
|
||||||
}
|
|
||||||
config.gpr_min_visible_score = static_cast<float>(found->get<double>());
|
|
||||||
}
|
|
||||||
for (const auto& [key, target] : {
|
for (const auto& [key, target] : {
|
||||||
std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m},
|
std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m},
|
||||||
std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m},
|
std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m},
|
||||||
@@ -388,6 +382,10 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
|||||||
config.gpr_draw_top_m_objects =
|
config.gpr_draw_top_m_objects =
|
||||||
parse_u32_number(*found, "processing.gpr_draw_top_m_objects");
|
parse_u32_number(*found, "processing.gpr_draw_top_m_objects");
|
||||||
}
|
}
|
||||||
|
if (const auto found = root.find("gpr_object_approach_min_frames"); found != root.end()) {
|
||||||
|
config.gpr_object_approach_min_frames =
|
||||||
|
parse_u32_number(*found, "processing.gpr_object_approach_min_frames");
|
||||||
|
}
|
||||||
if (const auto found = root.find("ignore_socket_speed"); found != root.end()) {
|
if (const auto found = root.find("ignore_socket_speed"); found != root.end()) {
|
||||||
if (!found->is_boolean()) {
|
if (!found->is_boolean()) {
|
||||||
throw std::runtime_error("processing.ignore_socket_speed must be bool");
|
throw std::runtime_error("processing.ignore_socket_speed must be bool");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "object_approach_filter.hpp"
|
||||||
#include "processor_interface.hpp"
|
#include "processor_interface.hpp"
|
||||||
|
|
||||||
namespace radar::processing {
|
namespace radar::processing {
|
||||||
@@ -13,6 +14,11 @@ class GprProcessor final : public ProcessorInterface {
|
|||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultCollection override;
|
) -> ipc::ResultCollection override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Cross-frame "approach" track filter. Persists across collections because the
|
||||||
|
// owning processor instance is long-lived (one per data_processor run).
|
||||||
|
ObjectApproachFilter approach_filter_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
class LegacyGprProcessor final : public ProcessorInterface {
|
class LegacyGprProcessor final : public ProcessorInterface {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <limits>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace radar::processing {
|
||||||
|
|
||||||
|
// Temporal "approach" filter for coherent-BP detections — a port of the objects-only
|
||||||
|
// filter in Horns_motion_3libre's demonstrate notebook.
|
||||||
|
//
|
||||||
|
// It keeps only objects that persist as a *motion-consistent track* across at least
|
||||||
|
// `min_frames` consecutive frames: as the radar moves, a real target reappears at a
|
||||||
|
// predictable, shifting (x, z), whereas a one-frame noise spike forms no track and is
|
||||||
|
// dropped. The expected per-frame change in range is `speed * dt * cos(look_angle)`,
|
||||||
|
// where `dt` is the real interval between consecutive frames (so dropped frames and a
|
||||||
|
// varying frame period are handled naturally).
|
||||||
|
//
|
||||||
|
// CAUSAL: unlike the offline notebook (which can look forward over the whole sequence),
|
||||||
|
// this confirms a track by looking *backward* — an object is kept once it ends a track
|
||||||
|
// of `min_frames` frames seen so far. A target therefore first appears after it has
|
||||||
|
// persisted `min_frames` frames; the early frames of its track are not shown
|
||||||
|
// retroactively.
|
||||||
|
//
|
||||||
|
// Stateless across pipeline restarts is approximated by breaking tracks across a large
|
||||||
|
// inter-frame gap (`kMaxFrameGapSeconds`), so a stale history from a previous run cannot
|
||||||
|
// spuriously confirm objects.
|
||||||
|
//
|
||||||
|
// IDEMPOTENT under reprocessing: the data_processor re-runs the last collection (or
|
||||||
|
// replays the whole window) whenever live settings or the socket speed change, with no
|
||||||
|
// new sweep. The history is therefore keyed by `frame_id` (the strictly increasing
|
||||||
|
// collection id): the same id replaces its entry (reprocess of the current frame), a
|
||||||
|
// smaller id rebuilds from scratch (a replay restart), so repeated reprocessing never
|
||||||
|
// duplicates frames or falsely confirms a track.
|
||||||
|
class ObjectApproachFilter {
|
||||||
|
public:
|
||||||
|
struct Point {
|
||||||
|
double x_m;
|
||||||
|
double z_m;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record `objects` as frame `frame_id` and return, per object, whether it is
|
||||||
|
// confirmed (ends a >= `min_frames` motion-consistent track). `frame_id` is the
|
||||||
|
// collection id (identity/order, survives reprocessing); `frame_time_seconds` is the
|
||||||
|
// frame's wall-clock timestamp (drives the inter-frame interval); `speed_m_s`/
|
||||||
|
// `look_angle_deg` are the live motion estimate. `min_frames <= 1` disables filtering.
|
||||||
|
[[nodiscard]] auto confirm(
|
||||||
|
const std::vector<Point>& objects,
|
||||||
|
std::uint64_t frame_id,
|
||||||
|
double frame_time_seconds,
|
||||||
|
double speed_m_s,
|
||||||
|
double look_angle_deg,
|
||||||
|
std::size_t min_frames
|
||||||
|
) -> std::vector<bool> {
|
||||||
|
record_frame(Frame{frame_id, frame_time_seconds, objects});
|
||||||
|
const std::size_t history_depth = std::max<std::size_t>(min_frames, 1U);
|
||||||
|
while (history_.size() > history_depth) {
|
||||||
|
history_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<bool> confirmed(objects.size(), min_frames <= 1U);
|
||||||
|
if (min_frames <= 1U || history_.size() < min_frames) {
|
||||||
|
return confirmed; // disabled, or not enough history yet to confirm anything
|
||||||
|
}
|
||||||
|
|
||||||
|
const double range_step_per_second = std::abs(speed_m_s) * std::cos(to_radians(look_angle_deg));
|
||||||
|
for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) {
|
||||||
|
confirmed[object_index] = has_backward_track(objects[object_index], min_frames, range_step_per_second);
|
||||||
|
}
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() { history_.clear(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Frame {
|
||||||
|
std::uint64_t id;
|
||||||
|
double time_seconds;
|
||||||
|
std::vector<Point> objects;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append a genuinely new frame, replace the current one on reprocessing (same id),
|
||||||
|
// or rebuild from scratch when the id steps backward (a replay restart). This keeps
|
||||||
|
// the history one entry per distinct collection no matter how often settings change.
|
||||||
|
void record_frame(Frame frame) {
|
||||||
|
if (history_.empty() || frame.id > history_.back().id) {
|
||||||
|
history_.push_back(std::move(frame));
|
||||||
|
} else if (frame.id == history_.back().id) {
|
||||||
|
history_.back() = std::move(frame);
|
||||||
|
} else {
|
||||||
|
history_.clear();
|
||||||
|
history_.push_back(std::move(frame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed matching tolerances (Horns_motion notebook 0.2 block). Range decreases as the
|
||||||
|
// radar approaches the target, hence the negative Z sign.
|
||||||
|
static constexpr double kXToleranceM = 0.45;
|
||||||
|
static constexpr double kRangeToleranceFraction = 0.85;
|
||||||
|
static constexpr double kRangeToleranceFloorM = 0.12;
|
||||||
|
static constexpr double kRangeSign = -1.0;
|
||||||
|
static constexpr double kMaxFrameGapSeconds = 2.0;
|
||||||
|
|
||||||
|
[[nodiscard]] static auto to_radians(double degrees) -> double {
|
||||||
|
return degrees * (M_PI / 180.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk back from the current object through the history, matching a motion-consistent
|
||||||
|
// predecessor in each earlier frame. Confirmed iff a full chain of `min_frames` frames
|
||||||
|
// (the current one plus `min_frames - 1` predecessors) is found.
|
||||||
|
[[nodiscard]] auto has_backward_track(
|
||||||
|
const Point& object,
|
||||||
|
std::size_t min_frames,
|
||||||
|
double range_step_per_second
|
||||||
|
) const -> bool {
|
||||||
|
const std::size_t newest = history_.size() - 1U;
|
||||||
|
Point current = object;
|
||||||
|
for (std::size_t step = 1U; step < min_frames; ++step) {
|
||||||
|
const std::size_t earlier_index = newest - step;
|
||||||
|
const Frame& earlier = history_[earlier_index];
|
||||||
|
const double dt = history_[earlier_index + 1U].time_seconds - earlier.time_seconds;
|
||||||
|
if (!(dt > 0.0) || dt > kMaxFrameGapSeconds) {
|
||||||
|
return false; // non-monotonic time, or a gap that breaks the track
|
||||||
|
}
|
||||||
|
const double expected_range_shift = range_step_per_second * dt;
|
||||||
|
const Point* predecessor = match_predecessor(current, earlier.objects, expected_range_shift);
|
||||||
|
if (predecessor == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
current = *predecessor;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The best earlier-frame object consistent with `object` having moved by one frame:
|
||||||
|
// its range was larger by `expected_range_shift` (radar since approached), within the
|
||||||
|
// cross-range and range tolerances. Returns nullptr when nothing matches.
|
||||||
|
[[nodiscard]] static auto match_predecessor(
|
||||||
|
const Point& object,
|
||||||
|
const std::vector<Point>& candidates,
|
||||||
|
double expected_range_shift
|
||||||
|
) -> const Point* {
|
||||||
|
const double target_z = object.z_m - (kRangeSign * expected_range_shift);
|
||||||
|
const double range_tolerance =
|
||||||
|
std::max(kRangeToleranceFloorM, kRangeToleranceFraction * expected_range_shift);
|
||||||
|
|
||||||
|
const Point* best = nullptr;
|
||||||
|
double best_cost = std::numeric_limits<double>::infinity();
|
||||||
|
for (const Point& candidate : candidates) {
|
||||||
|
const double dx = std::abs(candidate.x_m - object.x_m);
|
||||||
|
const double dz = std::abs(candidate.z_m - target_z);
|
||||||
|
if (dx > kXToleranceM || dz > range_tolerance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double cost = (dx / kXToleranceM) * (dx / kXToleranceM)
|
||||||
|
+ (dz / range_tolerance) * (dz / range_tolerance);
|
||||||
|
if (cost < best_cost) {
|
||||||
|
best = &candidate;
|
||||||
|
best_cost = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::deque<Frame> history_{}; // recent frames, newest at the back; capped to min_frames
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace radar::processing
|
||||||
+62
-14
@@ -21,7 +21,6 @@ constexpr double kSmoothSigma = 1.5;
|
|||||||
// same default 'reflect' (half-sample symmetric) extension — see reflect_index.
|
// same default 'reflect' (half-sample symmetric) extension — see reflect_index.
|
||||||
constexpr double kGaussianTruncate = 4.0;
|
constexpr double kGaussianTruncate = 4.0;
|
||||||
constexpr std::size_t kMaxObjects = 10U;
|
constexpr std::size_t kMaxObjects = 10U;
|
||||||
constexpr double kObjectMinFrac = 0.7;
|
|
||||||
constexpr double kRegionThresholdFrac = 0.75;
|
constexpr double kRegionThresholdFrac = 0.75;
|
||||||
constexpr double kSuppressThresholdFrac = 0.20;
|
constexpr double kSuppressThresholdFrac = 0.20;
|
||||||
constexpr double kSuppressRadiusXM = 0.80;
|
constexpr double kSuppressRadiusXM = 0.80;
|
||||||
@@ -1405,7 +1404,8 @@ void apply_depth_gate(
|
|||||||
|
|
||||||
[[nodiscard]] auto find_bp_objects(
|
[[nodiscard]] auto find_bp_objects(
|
||||||
const std::vector<double>& bp_image,
|
const std::vector<double>& bp_image,
|
||||||
const GridDefinition& grid
|
const GridDefinition& grid,
|
||||||
|
double min_frac
|
||||||
) -> std::vector<ObjectRecord> {
|
) -> std::vector<ObjectRecord> {
|
||||||
std::vector<ObjectRecord> objects{};
|
std::vector<ObjectRecord> objects{};
|
||||||
if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
||||||
@@ -1414,7 +1414,7 @@ void apply_depth_gate(
|
|||||||
|
|
||||||
std::vector<double> work = bp_image;
|
std::vector<double> work = bp_image;
|
||||||
const double global_peak = max_value(work);
|
const double global_peak = max_value(work);
|
||||||
const double stop_level = kObjectMinFrac * global_peak;
|
const double stop_level = min_frac * global_peak;
|
||||||
if (!(global_peak > 0.0)) {
|
if (!(global_peak > 0.0)) {
|
||||||
return objects;
|
return objects;
|
||||||
}
|
}
|
||||||
@@ -1740,7 +1740,15 @@ void add_bp_score_metrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] auto output_objects_sorted(
|
// Select the FINAL visible objects exactly as Horns_motion_3libre.py does, so the
|
||||||
|
// processor is the single source of truth: the GUI plot and the locator socket both
|
||||||
|
// consume this set verbatim (no second, duplicated filter). Steps, in order:
|
||||||
|
// 1. drop sidelobe candidates (when enabled), then sort by score (peak tie-break);
|
||||||
|
// 2. clip to the visible X/Z window (display window doubles as an object gate);
|
||||||
|
// 3. apply the N/M draw rule (BP_MAX_DETECTED_OBJECTS_TO_DRAW / BP_DRAW_TOP_M_OBJECTS):
|
||||||
|
// if more than N survive, show none; otherwise keep the top M.
|
||||||
|
// There is deliberately NO score threshold (the Python reference has none).
|
||||||
|
[[nodiscard]] auto select_visible_objects(
|
||||||
const std::vector<ObjectRecord>& objects,
|
const std::vector<ObjectRecord>& objects,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> std::vector<const ObjectRecord*> {
|
) -> std::vector<const ObjectRecord*> {
|
||||||
@@ -1750,6 +1758,12 @@ void add_bp_score_metrics(
|
|||||||
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
|
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (object.x_m < live_config.gpr_visible_x_min_m
|
||||||
|
|| object.x_m > live_config.gpr_visible_x_max_m
|
||||||
|
|| object.z_m < live_config.gpr_visible_z_min_m
|
||||||
|
|| object.z_m > live_config.gpr_visible_z_max_m) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
visible.push_back(&object);
|
visible.push_back(&object);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,6 +1773,17 @@ void add_bp_score_metrics(
|
|||||||
}
|
}
|
||||||
return left->selected_score > right->selected_score;
|
return left->selected_score > right->selected_score;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// N/M draw rule (0 on either disables limiting, mirroring the GUI/locator default).
|
||||||
|
const auto max_detected = live_config.gpr_max_detected_objects_to_draw;
|
||||||
|
const auto draw_top = live_config.gpr_draw_top_m_objects;
|
||||||
|
if (max_detected > 0U && draw_top > 0U) {
|
||||||
|
if (visible.size() > max_detected) {
|
||||||
|
visible.clear();
|
||||||
|
} else if (visible.size() > draw_top) {
|
||||||
|
visible.resize(draw_top);
|
||||||
|
}
|
||||||
|
}
|
||||||
return visible;
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1815,7 +1840,8 @@ void add_bp_score_metrics(
|
|||||||
const config::RunConfig& run_config,
|
const config::RunConfig& run_config,
|
||||||
const ipc::PreprocessedCollection& collection,
|
const ipc::PreprocessedCollection& collection,
|
||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config,
|
||||||
|
ObjectApproachFilter& approach_filter
|
||||||
) -> ipc::ResultCollection {
|
) -> ipc::ResultCollection {
|
||||||
ipc::ResultCollection results{};
|
ipc::ResultCollection results{};
|
||||||
results.collection_id = collection.collection_id;
|
results.collection_id = collection.collection_id;
|
||||||
@@ -1833,8 +1859,10 @@ void add_bp_score_metrics(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
const double velocity_mps =
|
// Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python
|
||||||
kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast<double>(run_config.gpr.relative_permittivity)));
|
// reference Horns_motion_3libre.py (its 0.3 block hardcodes eps_r = 1.0). The
|
||||||
|
// configurable relative_permittivity stays a legacy-GPR-only knob.
|
||||||
|
const double velocity_mps = kSpeedOfLightMetersPerSec;
|
||||||
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
|
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
|
||||||
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
|
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
|
||||||
const double min_depth_m = static_cast<double>(live_config.gpr_min_depth_m);
|
const double min_depth_m = static_cast<double>(live_config.gpr_min_depth_m);
|
||||||
@@ -1920,7 +1948,7 @@ void add_bp_score_metrics(
|
|||||||
min_depth_m,
|
min_depth_m,
|
||||||
max_depth_m,
|
max_depth_m,
|
||||||
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
|
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
|
||||||
std::max(0.0, static_cast<double>(live_config.gpr_angle_comp_power))
|
0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0)
|
||||||
);
|
);
|
||||||
if (bp.image.empty()) {
|
if (bp.image.empty()) {
|
||||||
return results;
|
return results;
|
||||||
@@ -1930,7 +1958,7 @@ void add_bp_score_metrics(
|
|||||||
const auto incoherent_display_map =
|
const auto incoherent_display_map =
|
||||||
normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma);
|
normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma);
|
||||||
|
|
||||||
auto objects = find_bp_objects(display_map, grid);
|
auto objects = find_bp_objects(display_map, grid, static_cast<double>(live_config.gpr_object_min_frac));
|
||||||
add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m);
|
add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m);
|
||||||
add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
|
add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
|
||||||
mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m);
|
mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m);
|
||||||
@@ -1951,14 +1979,34 @@ void add_bp_score_metrics(
|
|||||||
|
|
||||||
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map));
|
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map));
|
||||||
|
|
||||||
|
// Final visible set (window + N/M), then the cross-frame approach filter: keep only
|
||||||
|
// objects confirmed as a >= min_frames motion-consistent track (Horns_motion notebook).
|
||||||
|
const auto visible = select_visible_objects(objects, live_config);
|
||||||
|
std::vector<ObjectApproachFilter::Point> visible_points{};
|
||||||
|
visible_points.reserve(visible.size());
|
||||||
|
for (const auto* object : visible) {
|
||||||
|
visible_points.push_back({object->x_m, object->z_m});
|
||||||
|
}
|
||||||
|
const auto confirmed = approach_filter.confirm(
|
||||||
|
visible_points,
|
||||||
|
collection.collection_id,
|
||||||
|
static_cast<double>(collection.monotonic_ns) * 1e-9,
|
||||||
|
static_cast<double>(live_config.gpr_speed_m_s),
|
||||||
|
static_cast<double>(live_config.gpr_look_angle_deg),
|
||||||
|
static_cast<std::size_t>(live_config.gpr_object_approach_min_frames)
|
||||||
|
);
|
||||||
|
|
||||||
std::vector<std::vector<float>> point_rows{};
|
std::vector<std::vector<float>> point_rows{};
|
||||||
point_rows.reserve(objects.size());
|
point_rows.reserve(visible.size());
|
||||||
for (const auto* object : output_objects_sorted(objects, live_config)) {
|
for (std::size_t index = 0U; index < visible.size(); ++index) {
|
||||||
|
if (!confirmed[index]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
point_rows.push_back(
|
point_rows.push_back(
|
||||||
{
|
{
|
||||||
static_cast<float>(object->x_m),
|
static_cast<float>(visible[index]->x_m),
|
||||||
static_cast<float>(object->z_m),
|
static_cast<float>(visible[index]->z_m),
|
||||||
static_cast<float>(object->selected_score),
|
static_cast<float>(visible[index]->selected_score),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ auto GprProcessor::process_collection(
|
|||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultCollection {
|
) -> ipc::ResultCollection {
|
||||||
return process_backprojection_gpr(run_config, collection, previous_collections, live_config);
|
return process_backprojection_gpr(run_config, collection, previous_collections, live_config, approach_filter_);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto LegacyGprProcessor::name() const -> std::string {
|
auto LegacyGprProcessor::name() const -> std::string {
|
||||||
|
|||||||
@@ -147,14 +147,18 @@ class DriverLifecycleGuard {
|
|||||||
// Worst-case serialized size of a collection given the configured combo count and sweep
|
// Worst-case serialized size of a collection given the configured combo count and sweep
|
||||||
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
|
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
|
||||||
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
|
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
|
||||||
// + capture_start_ns(8) + capture_end_ns(8) = 40 bytes
|
// + capture_start_ns(8) + capture_end_ns(8)
|
||||||
|
// + trace capture window count(4) = 44 bytes
|
||||||
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
|
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
|
||||||
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
|
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
|
||||||
|
// + trailer: capture_start_ns(8) + capture_end_ns(8) = 16 bytes
|
||||||
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
|
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
|
||||||
constexpr std::size_t kCollectionHeaderBytes = 40U;
|
constexpr std::size_t kCollectionHeaderBytes = 44U;
|
||||||
constexpr std::size_t kTraceHeaderBytes = 12U;
|
constexpr std::size_t kTraceHeaderBytes = 12U;
|
||||||
constexpr std::size_t kBytesPerPoint = 20U;
|
constexpr std::size_t kBytesPerPoint = 20U;
|
||||||
const std::size_t per_trace = kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint);
|
constexpr std::size_t kTraceTrailerBytes = 16U;
|
||||||
|
const std::size_t per_trace =
|
||||||
|
kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint) + kTraceTrailerBytes;
|
||||||
return kCollectionHeaderBytes + (combo_count * per_trace);
|
return kCollectionHeaderBytes + (combo_count * per_trace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +211,7 @@ SweepOrchestrator::SweepOrchestrator(
|
|||||||
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
||||||
// Fail fast on a config error: a slot that is too small for the worst-case payload can
|
// Fail fast on a config error: a slot that is too small for the worst-case payload can
|
||||||
// never carry a full collection, so report it clearly at startup instead of dropping
|
// never carry a full collection, so report it clearly at startup instead of dropping
|
||||||
// every collection at runtime (fix #27).
|
// every collection at runtime.
|
||||||
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
|
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
|
||||||
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
|
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
@@ -219,7 +223,7 @@ void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
|
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
|
||||||
// Wait for the devices to become available before starting (fix #8/#9): an absent device
|
// Wait for the devices to become available before starting: an absent device
|
||||||
// makes the orchestrator wait, not exit.
|
// makes the orchestrator wait, not exit.
|
||||||
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
||||||
return; // stop requested before any device became available
|
return; // stop requested before any device became available
|
||||||
@@ -290,7 +294,12 @@ auto SweepOrchestrator::acquire_one_collection(
|
|||||||
// Production drivers ignore this; mock drivers use it to give every
|
// Production drivers ignore this; mock drivers use it to give every
|
||||||
// (input, output) pair its own synthetic response.
|
// (input, output) pair its own synthetic response.
|
||||||
radar_driver_.set_active_combo(combo);
|
radar_driver_.set_active_combo(combo);
|
||||||
|
// Stamp around the sweep only: the switch drive and settling above belong to
|
||||||
|
// neither the previous combo nor this one, so excluding them keeps the window
|
||||||
|
// an honest "when was this combo actually measured".
|
||||||
|
const auto sweep_start_ns = ipc::current_monotonic_ns();
|
||||||
auto sweep = radar_driver_.acquire_sweep();
|
auto sweep = radar_driver_.acquire_sweep();
|
||||||
|
const auto sweep_end_ns = ipc::current_monotonic_ns();
|
||||||
validate_sweep(sweep);
|
validate_sweep(sweep);
|
||||||
|
|
||||||
ipc::SweepTraceBlock trace{};
|
ipc::SweepTraceBlock trace{};
|
||||||
@@ -298,6 +307,8 @@ auto SweepOrchestrator::acquire_one_collection(
|
|||||||
trace.frequency_hz = std::move(sweep.frequency_hz);
|
trace.frequency_hz = std::move(sweep.frequency_hz);
|
||||||
trace.s11 = std::move(sweep.s11);
|
trace.s11 = std::move(sweep.s11);
|
||||||
trace.s21 = std::move(sweep.s21);
|
trace.s21 = std::move(sweep.s21);
|
||||||
|
trace.capture_start_ns = sweep_start_ns;
|
||||||
|
trace.capture_end_ns = sweep_end_ns;
|
||||||
collection.traces.push_back(std::move(trace));
|
collection.traces.push_back(std::move(trace));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Протокол старого пульта тележки (реверс-инжиниринг)
|
||||||
|
|
||||||
|
Снято 2026-08-20 осциллографом Hantek DPO7204C с сигнального провода пульта
|
||||||
|
(канал CH3). Инструменты: `tools/capture.py` (захват), `tools/decode.py`
|
||||||
|
(декодер), сырые данные и картинки — в `captures/`.
|
||||||
|
|
||||||
|
## Физический уровень
|
||||||
|
|
||||||
|
- Один сигнальный провод, логика **3.3 В**.
|
||||||
|
- **Инвертированный UART** (стандартная полярность SBUS): в покое линия
|
||||||
|
**низкая** (~0 В), импульсы вверх до ~3.3 В.
|
||||||
|
- Скорость **100 000 бод**, формат **8E2** (8 бит данных, чётность even,
|
||||||
|
2 стоп-бита), биты LSB-first. Длительность бита 10 мкс.
|
||||||
|
|
||||||
|
## Кадровый уровень — SBUS
|
||||||
|
|
||||||
|
Стандартный кадр Futaba SBUS, 25 байт (3 мс на линии):
|
||||||
|
|
||||||
|
| Смещение | Размер | Содержимое |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 1 | Заголовок `0x0F` |
|
||||||
|
| 1 | 22 | 16 каналов × 11 бит, упакованы подряд LSB-first |
|
||||||
|
| 23 | 1 | Флаги: bit0=CH17, bit1=CH18, bit2=frame_lost, bit3=failsafe |
|
||||||
|
| 24 | 1 | Футер `0x00` |
|
||||||
|
|
||||||
|
Распаковка каналов: 22 байта складываются в 176-битное число LSB-first,
|
||||||
|
канал N (N=0..15) = биты [11·N .. 11·N+10], диапазон значений 0–2047.
|
||||||
|
|
||||||
|
- Кадры отправляются каждые **50 мс** (20 Гц). Это медленнее стандартного
|
||||||
|
SBUS (7/14 мс) — ответная часть с этим темпом работает.
|
||||||
|
- Флаги во всех наблюдениях = `0x00`.
|
||||||
|
|
||||||
|
## Карта каналов (нумерация с 1)
|
||||||
|
|
||||||
|
| Управление | Канал | Мин | Нейтраль | Макс |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Стик вперёд/назад | **2** | 433 (назад) | 1024 | 1643 (вперёд) |
|
||||||
|
| Стик влево/вправо | **4** | 446 (влево) | 1024 | 1654 (вправо) |
|
||||||
|
| Остальные 14 | — | всегда 1024 | | |
|
||||||
|
|
||||||
|
Диапазон осей ~±600 от нейтрали (не полная шкала SBUS). Других органов
|
||||||
|
управления на пульте нет.
|
||||||
|
|
||||||
|
Эталонный кадр нейтрали (hex):
|
||||||
|
|
||||||
|
```
|
||||||
|
0F 00 04 20 00 01 08 40 00 02 10 80 00 04 20 00 01 08 40 00 02 10 80 00 00
|
||||||
|
```
|
||||||
|
|
||||||
|
## Воспроизведение на STM32G431
|
||||||
|
|
||||||
|
- USART: 100000 бод, 8 бит + чётность even (в терминах STM32: M=1, 9-bit
|
||||||
|
с PCE=1), 2 стоп-бита, **TXINV=1** (аппаратная инверсия TX) — бит-бэнг
|
||||||
|
не нужен.
|
||||||
|
- Отправлять 25-байтовый кадр по таймеру каждые 50 мс.
|
||||||
|
- Нейтраль: все каналы 1024; управление — каналы 2 и 4 в измеренных
|
||||||
|
диапазонах.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[env:weact_g431cb]
|
||||||
|
platform = ststm32
|
||||||
|
board = genericSTM32G431CB
|
||||||
|
framework = arduino
|
||||||
|
upload_protocol = stlink
|
||||||
|
debug_tool = stlink
|
||||||
|
monitor_speed = 115200
|
||||||
|
build_flags =
|
||||||
|
-DUSBCON
|
||||||
|
-DUSBD_USE_CDC
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Пульт тележки: стик (АЦП PA0/PA1) -> SBUS на USART1 TX (PA9).
|
||||||
|
// Протокол: инвертированный SBUS, 100000 бод 8E2, 25 байт каждые 50 мс
|
||||||
|
// (см. docs/protocol.md). USB CDC (Serial) — отладочный вывод.
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// ---- калибровка стика (АЦП 12 бит, замерено 2026-08-20) ----
|
||||||
|
static const int X_FWD = 650, X_MID = 2014, X_BACK = 3378; // PA0 (плечи равны: 2014-650 = 3378-2014 = 1364)
|
||||||
|
static const int Y_RIGHT = 479, Y_MID = 1981, Y_LEFT = 3586; // PA1
|
||||||
|
static const int DEADZONE = 15; // отсечка дребезга вокруг нейтрали
|
||||||
|
|
||||||
|
// ---- SBUS-значения старого пульта ----
|
||||||
|
static const uint16_t SBUS_MID = 1024;
|
||||||
|
static const uint16_t CH2_MIN = 433, CH2_MAX = 1643; // назад..вперёд
|
||||||
|
static const uint16_t CH4_MIN = 446, CH4_MAX = 1654; // влево..вправо
|
||||||
|
|
||||||
|
// ---- expo: 0 = линейно, 1 = максимально мягкая нейтраль ----
|
||||||
|
static const float EXPO_K = 0.0f;
|
||||||
|
// ---- общий масштаб выхода: 1.0 = диапазон старого пульта ----
|
||||||
|
static const float RANGE_SCALE = 0.75f;
|
||||||
|
|
||||||
|
// симметричные плечи: вперёд и назад дают одинаковый максимум
|
||||||
|
static const uint16_t CH2_SPAN = 591; // min(1643-1024, 1024-433)
|
||||||
|
static const uint16_t CH4_SPAN = 578; // min(1654-1024, 1024-446)
|
||||||
|
|
||||||
|
// ---- профиль газа/поворота ----
|
||||||
|
static const float MOVE_START = 0.15f; // старт движения, доля хода стика
|
||||||
|
static const float POWER_FWD = 0.60f; // потолок «вперёд»
|
||||||
|
static const float POWER_BACK = 0.50f; // потолок «назад»
|
||||||
|
static const float POWER_TURN = 0.70f; // потолок поворота
|
||||||
|
static const uint16_t CH2_DB = 221; // мёртвая зона приёмника тележки (ЗАМЕРИТЬ по монитору)
|
||||||
|
static const uint16_t CH4_DB = 221;
|
||||||
|
|
||||||
|
static const uint32_t FRAME_PERIOD_MS = 50;
|
||||||
|
static const uint32_t PIN_X = PA0;
|
||||||
|
static const uint32_t PIN_Y = PA1;
|
||||||
|
|
||||||
|
static UART_HandleTypeDef s_sbusUart;
|
||||||
|
|
||||||
|
// USART1 TX = PA9 (AF7), 100000 бод, 8E2, TX инвертирован
|
||||||
|
static void sbusUartInit() {
|
||||||
|
__HAL_RCC_GPIOA_CLK_ENABLE();
|
||||||
|
__HAL_RCC_USART1_CLK_ENABLE();
|
||||||
|
|
||||||
|
GPIO_InitTypeDef gpio = {};
|
||||||
|
gpio.Pin = GPIO_PIN_9;
|
||||||
|
gpio.Mode = GPIO_MODE_AF_PP;
|
||||||
|
gpio.Pull = GPIO_NOPULL;
|
||||||
|
gpio.Speed = GPIO_SPEED_FREQ_LOW;
|
||||||
|
gpio.Alternate = GPIO_AF7_USART1;
|
||||||
|
HAL_GPIO_Init(GPIOA, &gpio);
|
||||||
|
|
||||||
|
s_sbusUart.Instance = USART1;
|
||||||
|
s_sbusUart.Init.BaudRate = 100000;
|
||||||
|
s_sbusUart.Init.WordLength = UART_WORDLENGTH_9B; // 8 данных + чётность
|
||||||
|
s_sbusUart.Init.StopBits = UART_STOPBITS_2;
|
||||||
|
s_sbusUart.Init.Parity = UART_PARITY_EVEN;
|
||||||
|
s_sbusUart.Init.Mode = UART_MODE_TX;
|
||||||
|
s_sbusUart.Init.HwFlowCtl = UART_HWCONTROL_NONE;
|
||||||
|
s_sbusUart.Init.OverSampling = UART_OVERSAMPLING_16;
|
||||||
|
s_sbusUart.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_TXINVERT_INIT;
|
||||||
|
s_sbusUart.AdvancedInit.TxPinLevelInvert = UART_ADVFEATURE_TXINV_ENABLE;
|
||||||
|
HAL_UART_Init(&s_sbusUart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// нормализация одной оси в [-1..1] с мёртвой зоной и асимметричными плечами
|
||||||
|
static float axisNorm(int adc, int lowEnd, int mid, int highEnd) {
|
||||||
|
float x;
|
||||||
|
if (adc < mid - DEADZONE) {
|
||||||
|
x = (float)(mid - adc) / (float)(mid - lowEnd); // к lowEnd -> +1
|
||||||
|
} else if (adc > mid + DEADZONE) {
|
||||||
|
x = -(float)(adc - mid) / (float)(highEnd - mid); // к highEnd -> -1
|
||||||
|
} else {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
return constrain(x, -1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// expo-кривая: гасит чувствительность у нейтрали, сохраняет края
|
||||||
|
static float expo(float x) {
|
||||||
|
return EXPO_K * x * x * x + (1.0f - EXPO_K) * x;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t toSbus(float x, uint16_t span) {
|
||||||
|
return (uint16_t)(SBUS_MID + lroundf(x * span));
|
||||||
|
}
|
||||||
|
|
||||||
|
// стик [-1..1] -> SBUS-значение канала: ниже MOVE_START — нейтраль, выше —
|
||||||
|
// линейно от порога срабатывания приёмника (db) до POWER_FRAC*span на полном стике
|
||||||
|
static uint16_t driveToSbus(float x, uint16_t span, uint16_t db,
|
||||||
|
float powerPos, float powerNeg) {
|
||||||
|
float mag = fabsf(x);
|
||||||
|
if (mag <= MOVE_START)
|
||||||
|
return SBUS_MID;
|
||||||
|
float outMax = (x > 0.0f ? powerPos : powerNeg) * span;
|
||||||
|
float t = (mag - MOVE_START) / (1.0f - MOVE_START);
|
||||||
|
long delta = lroundf(db + t * (outMax - db));
|
||||||
|
return (x > 0.0f) ? (uint16_t)(SBUS_MID + delta)
|
||||||
|
: (uint16_t)(SBUS_MID - delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sbusPack(uint8_t out[25], const uint16_t ch[16]) {
|
||||||
|
out[0] = 0x0F;
|
||||||
|
memset(out + 1, 0, 22);
|
||||||
|
uint32_t bitpos = 0;
|
||||||
|
for (int n = 0; n < 16; n++) {
|
||||||
|
for (int b = 0; b < 11; b++) {
|
||||||
|
if (ch[n] & (1u << b))
|
||||||
|
out[1 + (bitpos >> 3)] |= 1u << (bitpos & 7);
|
||||||
|
bitpos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[23] = 0x00; // флаги
|
||||||
|
out[24] = 0x00; // футер
|
||||||
|
}
|
||||||
|
|
||||||
|
static int readAvg(uint32_t pin) {
|
||||||
|
uint32_t acc = 0;
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
acc += analogRead(pin);
|
||||||
|
return acc / 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
analogReadResolution(12);
|
||||||
|
pinMode(PIN_X, INPUT_ANALOG);
|
||||||
|
pinMode(PIN_Y, INPUT_ANALOG);
|
||||||
|
sbusUartInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
static uint32_t next = 0;
|
||||||
|
uint32_t now = millis();
|
||||||
|
if (now < next)
|
||||||
|
return;
|
||||||
|
next = now + FRAME_PERIOD_MS;
|
||||||
|
|
||||||
|
int adcX = readAvg(PIN_X);
|
||||||
|
int adcY = readAvg(PIN_Y);
|
||||||
|
// вперёд = adcX к X_FWD (вниз) -> +1; вправо = adcY к Y_RIGHT -> +1
|
||||||
|
float fwd = axisNorm(adcX, X_FWD, X_MID, X_BACK);
|
||||||
|
float right = axisNorm(adcY, Y_RIGHT, Y_MID, Y_LEFT);
|
||||||
|
|
||||||
|
uint16_t ch[16];
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
ch[i] = SBUS_MID;
|
||||||
|
ch[1] = driveToSbus(fwd, CH2_SPAN, CH2_DB, POWER_FWD, POWER_BACK); // канал 2 — газ
|
||||||
|
ch[3] = driveToSbus(right, CH4_SPAN, CH4_DB, POWER_TURN, POWER_TURN); // канал 4 — поворот
|
||||||
|
|
||||||
|
uint8_t frame[25];
|
||||||
|
sbusPack(frame, ch);
|
||||||
|
HAL_UART_Transmit(&s_sbusUart, frame, sizeof(frame), 20);
|
||||||
|
|
||||||
|
Serial.print("adc=");
|
||||||
|
Serial.print(adcX);
|
||||||
|
Serial.print(",");
|
||||||
|
Serial.print(adcY);
|
||||||
|
Serial.print(" fwd=");
|
||||||
|
Serial.print(fwd, 3);
|
||||||
|
Serial.print(" right=");
|
||||||
|
Serial.print(right, 3);
|
||||||
|
Serial.print(" ch2=");
|
||||||
|
Serial.print(ch[1]);
|
||||||
|
Serial.print(" ch4=");
|
||||||
|
Serial.println(ch[3]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analyze a captured frame: extract pulse timing structure.
|
||||||
|
|
||||||
|
Usage: .venv/bin/python tools/analyze.py captures/<name>.npz
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
d = np.load(path)
|
||||||
|
volts = d["volts"]
|
||||||
|
srate = float(d["srate"])
|
||||||
|
dt_us = 1e6 / srate
|
||||||
|
|
||||||
|
# threshold midway between the two dominant plateaus
|
||||||
|
hi_level = np.median(volts) # idle dominates the frame -> median = idle (high)
|
||||||
|
lo_level = np.percentile(volts, 10)
|
||||||
|
thr = (hi_level + lo_level) / 2
|
||||||
|
bits = (volts > thr).astype(np.int8)
|
||||||
|
print(f"levels: high~{hi_level:.2f} low~{lo_level:.2f} thr={thr:.2f} (raw units)")
|
||||||
|
|
||||||
|
# run-length encode
|
||||||
|
edges = np.flatnonzero(np.diff(bits)) + 1
|
||||||
|
starts = np.concatenate(([0], edges))
|
||||||
|
ends = np.concatenate((edges, [len(bits)]))
|
||||||
|
levels = bits[starts]
|
||||||
|
dur_us = (ends - starts) * dt_us
|
||||||
|
|
||||||
|
print(f"{len(levels)} runs total")
|
||||||
|
print("\nidx level dur_us (first/last runs are idle padding)")
|
||||||
|
for i, (lv, du) in enumerate(zip(levels, dur_us)):
|
||||||
|
tag = "H" if lv else "L"
|
||||||
|
print(f"{i:4d} {tag} {du:10.2f}")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture one single-shot CH3 frame from Hantek DPO7204C, save raw + PNG.
|
||||||
|
|
||||||
|
Flow: query settings -> :SINGle -> poll :TRIGger:STATus? until STOP ->
|
||||||
|
WAVeform:DATA:ALL? CHANnel3 (drained completely, multi-packet aware).
|
||||||
|
Scope is left in STOP so the on-screen frame matches the saved data.
|
||||||
|
|
||||||
|
Usage: .venv/bin/python tools/capture.py <name> [device]
|
||||||
|
Saves captures/<name>.bin, captures/<name>.npz, captures/<name>.png
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
DEV = sys.argv[2] if len(sys.argv) > 2 else "/dev/usbtmc2"
|
||||||
|
NAME = sys.argv[1] if len(sys.argv) > 1 else "capture"
|
||||||
|
OUTDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "captures")
|
||||||
|
os.makedirs(OUTDIR, exist_ok=True)
|
||||||
|
|
||||||
|
FIRST_HDR = 11 + 117 # '#9'+9-digit len, then 18 bytes counters + 99 bytes info
|
||||||
|
NEXT_HDR = 11 + 18 # follow-up packets: counters only
|
||||||
|
|
||||||
|
|
||||||
|
def read_exact(fd, n):
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = os.read(fd, min(1 << 20, n - len(buf)))
|
||||||
|
if not chunk:
|
||||||
|
raise IOError("short read from scope")
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def read_packet(fd):
|
||||||
|
head = read_exact(fd, 11)
|
||||||
|
assert head[:2] == b"#9", f"bad packet start: {head!r}"
|
||||||
|
pkt_len = int(head[2:11])
|
||||||
|
body = read_exact(fd, pkt_len)
|
||||||
|
return head + body
|
||||||
|
|
||||||
|
|
||||||
|
def query(fd, cmd):
|
||||||
|
os.write(fd, cmd.encode() + b"\n")
|
||||||
|
return os.read(fd, 256).decode(errors="replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
fd = os.open(DEV, os.O_RDWR)
|
||||||
|
print("IDN:", query(fd, "*IDN?"))
|
||||||
|
|
||||||
|
tdiv = float(query(fd, ":TIMebase:SCALe?"))
|
||||||
|
vdiv = float(query(fd, ":CHANnel3:SCALe?"))
|
||||||
|
voff = float(query(fd, ":CHANnel3:OFFSet?"))
|
||||||
|
print(f"tdiv={tdiv} s/div vdiv={vdiv} V/div offset={voff} V")
|
||||||
|
|
||||||
|
os.write(fd, b":SINGle\n")
|
||||||
|
for _ in range(100):
|
||||||
|
time.sleep(0.1)
|
||||||
|
st = query(fd, ":TRIGger:STATus?")
|
||||||
|
if st == "STOP":
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
sys.exit(f"scope did not reach STOP (last status: {st})")
|
||||||
|
print("status: STOP (frame captured)")
|
||||||
|
|
||||||
|
srate = float(query(fd, ":ACQuire:SRATe?"))
|
||||||
|
print(f"srate={srate:.3e} Sa/s")
|
||||||
|
|
||||||
|
os.write(fd, b"WAVeform:DATA:ALL? CHANnel3\n")
|
||||||
|
pkt = read_packet(fd)
|
||||||
|
total_len = int(pkt[11:20])
|
||||||
|
info_hdr = pkt[29:FIRST_HDR]
|
||||||
|
data = pkt[FIRST_HDR:]
|
||||||
|
raw_all = pkt
|
||||||
|
while len(data) < total_len:
|
||||||
|
os.write(fd, b"WAVeform:DATA:ALL? CHANnel3\n")
|
||||||
|
p = read_packet(fd)
|
||||||
|
raw_all += p
|
||||||
|
data += p[NEXT_HDR:]
|
||||||
|
print(f"received {len(data)} samples (declared {total_len})")
|
||||||
|
print("info header hex:", info_hdr.hex(" "))
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
raw = np.frombuffer(data[:total_len], dtype=np.uint8).astype(np.float32)
|
||||||
|
# unsigned 8-bit, 25.6 levels/div, mid-screen = code 128, offset shifts zero
|
||||||
|
volts = (raw - 128.0) / 25.6 * vdiv - voff
|
||||||
|
t = np.arange(len(volts)) / srate * 1e3 # ms
|
||||||
|
|
||||||
|
base = os.path.join(OUTDIR, NAME)
|
||||||
|
with open(base + ".bin", "wb") as f:
|
||||||
|
f.write(raw_all)
|
||||||
|
np.savez(base + ".npz", volts=volts, srate=srate, vdiv=vdiv, voff=voff, tdiv=tdiv)
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(2, 1, figsize=(16, 8))
|
||||||
|
axes[0].plot(t, volts, lw=0.5)
|
||||||
|
axes[0].set_title(f"{NAME} — full frame ({srate:.0e} Sa/s, {vdiv} V/div, off {voff} V)")
|
||||||
|
# zoom on activity: region where signal deviates from its median
|
||||||
|
dev_idx = np.where(np.abs(volts - np.median(volts)) > 0.5)[0]
|
||||||
|
if len(dev_idx):
|
||||||
|
lo = max(0, dev_idx[0] - int(0.05 * (dev_idx[-1] - dev_idx[0] + 1)) - 100)
|
||||||
|
hi = min(len(volts), dev_idx[-1] + int(0.05 * (dev_idx[-1] - dev_idx[0] + 1)) + 100)
|
||||||
|
axes[1].plot(t[lo:hi], volts[lo:hi], lw=0.7)
|
||||||
|
axes[1].set_title("zoom on activity")
|
||||||
|
for ax in axes:
|
||||||
|
ax.set_xlabel("t, ms")
|
||||||
|
ax.set_ylabel("U, V")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(base + ".png", dpi=110)
|
||||||
|
print("saved:", base + ".png")
|
||||||
|
print(f"range: min={volts.min():.3f} V max={volts.max():.3f} V median={np.median(volts):.3f} V")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rigorous comparison of two SBUS captures (old remote vs our firmware).
|
||||||
|
|
||||||
|
Usage: .venv/bin/python tools/compare.py captures/a.npz captures/b.npz
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def extract(path):
|
||||||
|
d = np.load(path)
|
||||||
|
v = d["volts"]
|
||||||
|
srate = float(d["srate"])
|
||||||
|
idle = np.median(v)
|
||||||
|
p10, p90 = np.percentile(v, [10, 90])
|
||||||
|
active = p10 if abs(p10 - idle) > abs(p90 - idle) else p90
|
||||||
|
thr = (idle + active) / 2
|
||||||
|
phys_hi = v > thr # physical high (pulse)
|
||||||
|
# plateau levels: median of samples well inside each state
|
||||||
|
lvl_hi = np.median(v[phys_hi])
|
||||||
|
lvl_lo = np.median(v[~phys_hi])
|
||||||
|
# runs
|
||||||
|
sig = phys_hi.astype(np.int8)
|
||||||
|
edges = np.flatnonzero(np.diff(sig)) + 1
|
||||||
|
starts = np.concatenate(([0], edges))
|
||||||
|
ends = np.concatenate((edges, [len(sig)]))
|
||||||
|
levels = sig[starts]
|
||||||
|
dur_us = (ends - starts) * 1e6 / srate
|
||||||
|
# burst envelope: first to last physical-high sample
|
||||||
|
hi_idx = np.flatnonzero(phys_hi)
|
||||||
|
envelope_us = (hi_idx[-1] - hi_idx[0] + 1) * 1e6 / srate
|
||||||
|
# inner runs (drop leading/trailing idle)
|
||||||
|
runs = [(int(l), float(du)) for l, du in zip(levels[1:-1], dur_us[1:-1])]
|
||||||
|
# UART logic: logic1 == idle state; idle here is physical low
|
||||||
|
stream = []
|
||||||
|
for l, du in runs:
|
||||||
|
n = max(1, round(du / 10.0))
|
||||||
|
stream.extend([1 - l] * n) # physical high -> logic 0
|
||||||
|
stream.extend([1] * 24)
|
||||||
|
frames = []
|
||||||
|
i = 0
|
||||||
|
while i + 12 <= len(stream):
|
||||||
|
if stream[i] == 1:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
byte = sum(b << k for k, b in enumerate(stream[i + 1 : i + 9]))
|
||||||
|
frames.append(byte)
|
||||||
|
i += 12
|
||||||
|
# bit clock estimate: envelope should be 299 bits (last stop bits merge w/ idle)
|
||||||
|
n_units = round(envelope_us / 10.0)
|
||||||
|
bit_us = envelope_us / n_units
|
||||||
|
return {
|
||||||
|
"lvl_hi": lvl_hi,
|
||||||
|
"lvl_lo": lvl_lo,
|
||||||
|
"runs": runs,
|
||||||
|
"frames": frames,
|
||||||
|
"envelope_us": envelope_us,
|
||||||
|
"bit_us": bit_us,
|
||||||
|
"vmin": float(v.min()),
|
||||||
|
"vmax": float(v.max()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
a_path, b_path = sys.argv[1], sys.argv[2]
|
||||||
|
A, B = extract(a_path), extract(b_path)
|
||||||
|
|
||||||
|
print(f"{'':24s} {'A: ' + a_path:>28s} {'B: ' + b_path:>28s}")
|
||||||
|
print(f"{'bytes decoded':24s} {len(A['frames']):>28d} {len(B['frames']):>28d}")
|
||||||
|
ha = " ".join(f"{x:02X}" for x in A["frames"])
|
||||||
|
hb = " ".join(f"{x:02X}" for x in B["frames"])
|
||||||
|
print(f"frames identical: {A['frames'] == B['frames']}")
|
||||||
|
print(" A:", ha)
|
||||||
|
print(" B:", hb)
|
||||||
|
print(f"{'run count':24s} {len(A['runs']):>28d} {len(B['runs']):>28d}")
|
||||||
|
qa = [round(du / 10) for _, du in A["runs"]]
|
||||||
|
qb = [round(du / 10) for _, du in B["runs"]]
|
||||||
|
la = [l for l, _ in A["runs"]]
|
||||||
|
lb = [l for l, _ in B["runs"]]
|
||||||
|
print(f"quantized run pattern identical: {qa == qb and la == lb}")
|
||||||
|
print(f"{'envelope, us':24s} {A['envelope_us']:>28.2f} {B['envelope_us']:>28.2f}")
|
||||||
|
print(f"{'bit time, us':24s} {A['bit_us']:>28.4f} {B['bit_us']:>28.4f}")
|
||||||
|
print(f"{'-> baud':24s} {1e6/A['bit_us']:>28.1f} {1e6/B['bit_us']:>28.1f}")
|
||||||
|
print(f"{'high plateau, V':24s} {A['lvl_hi']:>28.3f} {B['lvl_hi']:>28.3f}")
|
||||||
|
print(f"{'low plateau, V':24s} {A['lvl_lo']:>28.3f} {B['lvl_lo']:>28.3f}")
|
||||||
|
print(f"{'abs min/max, V':24s} {A['vmin']:>14.2f}/{A['vmax']:>12.2f} {B['vmin']:>14.2f}/{B['vmax']:>12.2f}")
|
||||||
|
|
||||||
|
# worst run deviation from ideal 10us grid
|
||||||
|
da = max(abs(du - 10 * round(du / 10)) for _, du in A["runs"])
|
||||||
|
db = max(abs(du - 10 * round(du / 10)) for _, du in B["runs"])
|
||||||
|
print(f"{'worst grid dev, us':24s} {da:>28.2f} {db:>28.2f}")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode a captured frame as SBUS: UART 100 kbit/s 8E2, 25-byte frame,
|
||||||
|
16 channels x 11 bits.
|
||||||
|
|
||||||
|
Usage: .venv/bin/python tools/decode.py captures/<name>.npz
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
BIT_US = 10.0
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
d = np.load(path)
|
||||||
|
volts = d["volts"]
|
||||||
|
srate = float(d["srate"])
|
||||||
|
dt_us = 1e6 / srate
|
||||||
|
|
||||||
|
# idle level dominates the record; UART logic 1 == idle regardless of
|
||||||
|
# physical polarity (this line is standard inverted SBUS: idle low)
|
||||||
|
idle = np.median(volts)
|
||||||
|
p10, p90 = np.percentile(volts, [10, 90])
|
||||||
|
active = p10 if abs(p10 - idle) > abs(p90 - idle) else p90
|
||||||
|
thr = (idle + active) / 2
|
||||||
|
if active > idle:
|
||||||
|
sig = (volts < thr).astype(np.int8) # pulses up -> logic 0
|
||||||
|
else:
|
||||||
|
sig = (volts > thr).astype(np.int8)
|
||||||
|
|
||||||
|
edges = np.flatnonzero(np.diff(sig)) + 1
|
||||||
|
starts = np.concatenate(([0], edges))
|
||||||
|
ends = np.concatenate((edges, [len(sig)]))
|
||||||
|
levels = sig[starts]
|
||||||
|
dur_us = (ends - starts) * dt_us
|
||||||
|
|
||||||
|
stream = []
|
||||||
|
for lv, du in zip(levels[1:-1], dur_us[1:-1]):
|
||||||
|
stream.extend([int(lv)] * max(1, round(du / BIT_US)))
|
||||||
|
# trailing idle of last stop bits is trimmed by run cut; pad with idle-high
|
||||||
|
stream.extend([1] * 24)
|
||||||
|
|
||||||
|
# deframe 8E2: start=0, 8 data LSB-first, even parity, 2 stop=1
|
||||||
|
i = 0
|
||||||
|
frames = []
|
||||||
|
errors = []
|
||||||
|
while i + 12 <= len(stream):
|
||||||
|
if stream[i] == 1:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
data = stream[i + 1 : i + 9]
|
||||||
|
par = stream[i + 9]
|
||||||
|
stops = stream[i + 10 : i + 12]
|
||||||
|
byte = sum(b << k for k, b in enumerate(data))
|
||||||
|
if par != (sum(data) & 1):
|
||||||
|
errors.append((len(frames), "parity"))
|
||||||
|
if stops != [1, 1]:
|
||||||
|
errors.append((len(frames), f"stop={stops}"))
|
||||||
|
frames.append(byte)
|
||||||
|
i += 12
|
||||||
|
|
||||||
|
print(f"decoded {len(frames)} bytes, errors: {errors if errors else 'none'}")
|
||||||
|
print("hex:", " ".join(f"{b:02X}" for b in frames))
|
||||||
|
|
||||||
|
if len(frames) >= 25 and frames[0] == 0x0F:
|
||||||
|
payload = frames[1:23]
|
||||||
|
flags = frames[23]
|
||||||
|
footer = frames[24]
|
||||||
|
bits = 0
|
||||||
|
for k, b in enumerate(payload):
|
||||||
|
bits |= b << (8 * k)
|
||||||
|
ch = [(bits >> (11 * n)) & 0x7FF for n in range(16)]
|
||||||
|
print("\nSBUS frame OK" if footer == 0x00 else f"\nfooter unexpected: {footer:02X}")
|
||||||
|
print("channels:", ch)
|
||||||
|
print(f"flags: 0x{flags:02X} (bit0=ch17 bit1=ch18 bit2=frame_lost bit3=failsafe)")
|
||||||
|
else:
|
||||||
|
print("not a valid SBUS frame (no 0x0F header)")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal SCPI helper for Hantek DPO7204C over /dev/usbtmc2."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
DEV = "/dev/usbtmc2"
|
||||||
|
|
||||||
|
|
||||||
|
class Scope:
|
||||||
|
def __init__(self, dev=DEV):
|
||||||
|
self.fd = os.open(dev, os.O_RDWR)
|
||||||
|
|
||||||
|
def write(self, cmd: str):
|
||||||
|
os.write(self.fd, cmd.encode() + b"\n")
|
||||||
|
|
||||||
|
def read(self, n=1 << 20, timeout=3.0) -> bytes:
|
||||||
|
# usbtmc read returns one transfer chunk; loop until short read
|
||||||
|
chunks = []
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
try:
|
||||||
|
data = os.read(self.fd, n)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
chunks.append(data)
|
||||||
|
if not data or len(data) < n:
|
||||||
|
break
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
def query(self, cmd: str, timeout=3.0) -> str:
|
||||||
|
self.write(cmd)
|
||||||
|
return self.read(timeout=timeout).decode(errors="replace").strip()
|
||||||
|
|
||||||
|
def query_raw(self, cmd: str, timeout=5.0) -> bytes:
|
||||||
|
self.write(cmd)
|
||||||
|
return self.read(timeout=timeout)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
os.close(self.fd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
s = Scope()
|
||||||
|
for cmd in sys.argv[1:]:
|
||||||
|
if cmd.endswith("?"):
|
||||||
|
print(f"{cmd:40s} -> {s.query(cmd)}")
|
||||||
|
else:
|
||||||
|
s.write(cmd)
|
||||||
|
print(f"{cmd:40s} [sent]")
|
||||||
|
s.close()
|
||||||
+106
-42
@@ -15,9 +15,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
from PyQt6.QtCore import QTimer
|
||||||
from PyQt6.QtGui import QTextCursor
|
from PyQt6.QtGui import QTextCursor
|
||||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||||
|
|
||||||
@@ -58,33 +59,60 @@ def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]
|
|||||||
return {"panel_details": details, "panel_once_key": once_key}
|
return {"panel_details": details, "panel_once_key": once_key}
|
||||||
|
|
||||||
|
|
||||||
class _PanelLogBridge(QObject):
|
class _PanelLogBuffer:
|
||||||
"""Marshals log records from any thread onto the GUI thread for panel rendering.
|
"""Thread-safe bounded buffer between logging handlers and the GUI flush timer.
|
||||||
|
|
||||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
|
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster)
|
||||||
but the log widget may only be touched on the GUI thread; emitting this queued
|
at a very high rate — e.g. the USB RX threads while the free-running sweep
|
||||||
signal hands the record across safely (the GPIO-button pattern).
|
streams. Posting one queued Qt event per record used to flood the GUI event
|
||||||
|
queue and keep the interface frozen long after a blocking operation finished
|
||||||
|
while the backlog rendered. Instead, records land in this bounded buffer and
|
||||||
|
a periodic GUI-side timer drains them in one batch; overflow drops the oldest
|
||||||
|
records and reports how many were lost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
|
_CAPACITY = 2000
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY)
|
||||||
|
self._dropped_count = 0
|
||||||
|
|
||||||
|
def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None:
|
||||||
|
"""Store one record, evicting the oldest when full (any thread)."""
|
||||||
|
with self._lock:
|
||||||
|
if len(self._entries) == self._CAPACITY:
|
||||||
|
self._dropped_count += 1
|
||||||
|
self._entries.append((level, text, details, once_key))
|
||||||
|
|
||||||
|
def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]:
|
||||||
|
"""Return and clear all buffered records plus the overflow-drop count."""
|
||||||
|
with self._lock:
|
||||||
|
entries = list(self._entries)
|
||||||
|
self._entries.clear()
|
||||||
|
dropped_count = self._dropped_count
|
||||||
|
self._dropped_count = 0
|
||||||
|
return entries, dropped_count
|
||||||
|
|
||||||
|
|
||||||
class _QtLogPanelHandler(logging.Handler):
|
class _QtLogPanelHandler(logging.Handler):
|
||||||
"""Logging handler that forwards application log records to the GUI log panel."""
|
"""Logging handler that forwards application log records to the GUI log panel."""
|
||||||
|
|
||||||
def __init__(self, bridge: _PanelLogBridge) -> None:
|
def __init__(self, buffer: _PanelLogBuffer) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._bridge = bridge
|
self._buffer = buffer
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
|
"""Buffer one record for the panel, mapping WARNING to the short 'WARN'."""
|
||||||
try:
|
try:
|
||||||
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
|
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
|
||||||
self._bridge.record.emit(
|
details = getattr(record, "panel_details", None)
|
||||||
|
once_key = getattr(record, "panel_once_key", None)
|
||||||
|
self._buffer.append(
|
||||||
display_level,
|
display_level,
|
||||||
record.getMessage(),
|
record.getMessage(),
|
||||||
getattr(record, "panel_details", None),
|
details if isinstance(details, str) else None,
|
||||||
getattr(record, "panel_once_key", None),
|
once_key if isinstance(once_key, str) else None,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - logging must never raise into the caller
|
except Exception: # noqa: BLE001 - logging must never raise into the caller
|
||||||
self.handleError(record)
|
self.handleError(record)
|
||||||
@@ -145,23 +173,53 @@ class AppWindow(
|
|||||||
log_dir = self._project_root / "python_app/runtime/logs"
|
log_dir = self._project_root / "python_app/runtime/logs"
|
||||||
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
|
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
|
||||||
self._gui_logger = get_logger("gui")
|
self._gui_logger = get_logger("gui")
|
||||||
self._log_panel_bridge = _PanelLogBridge()
|
self._log_panel_buffer = _PanelLogBuffer()
|
||||||
self._log_panel_bridge.record.connect(self._on_log_record)
|
|
||||||
|
|
||||||
def _attach_log_panel(self) -> None:
|
def _attach_log_panel(self) -> None:
|
||||||
"""Route application log records into the on-screen panel (widget now exists)."""
|
"""Route application log records into the on-screen panel (widget now exists)."""
|
||||||
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
|
add_handler(_QtLogPanelHandler(self._log_panel_buffer))
|
||||||
|
# One bounded flush per tick instead of one queued event per record: the
|
||||||
|
# panel can never flood the GUI event queue, no matter how chatty a
|
||||||
|
# DEBUG-level driver gets.
|
||||||
|
self._log_flush_timer = QTimer(self)
|
||||||
|
self._log_flush_timer.setInterval(100)
|
||||||
|
self._log_flush_timer.timeout.connect(self._flush_log_panel_buffer)
|
||||||
|
self._log_flush_timer.start()
|
||||||
|
|
||||||
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
|
def _flush_log_panel_buffer(self) -> None:
|
||||||
"""Render one forwarded log record in the panel (always on the GUI thread)."""
|
"""Render every buffered log record into the panel as one batched insert."""
|
||||||
if not hasattr(self, "_log_box"):
|
entries, dropped_count = self._log_panel_buffer.drain()
|
||||||
|
if (not entries and not dropped_count) or not hasattr(self, "_log_box"):
|
||||||
return
|
return
|
||||||
self._append_log_entry(
|
|
||||||
level,
|
entry_htmls: list[str] = []
|
||||||
text,
|
if dropped_count:
|
||||||
details=details if isinstance(details, str) else None,
|
entry_htmls.append(
|
||||||
once_key=once_key if isinstance(once_key, str) else None,
|
self._render_log_entry_html(
|
||||||
)
|
"WARN",
|
||||||
|
f"Log panel overflow: {dropped_count} record(s) dropped "
|
||||||
|
"(they are still in the log file).",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
error_seen = False
|
||||||
|
for level, text, details, once_key in entries:
|
||||||
|
if once_key is not None:
|
||||||
|
if once_key in self._logged_once_keys:
|
||||||
|
continue
|
||||||
|
self._logged_once_keys.add(once_key)
|
||||||
|
entry_htmls.append(self._render_log_entry_html(level, text, details))
|
||||||
|
error_seen = error_seen or level.upper() == "ERROR"
|
||||||
|
|
||||||
|
if not entry_htmls:
|
||||||
|
return
|
||||||
|
cursor = self._log_box.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||||
|
self._log_box.setTextCursor(cursor)
|
||||||
|
self._log_box.insertHtml("".join(entry_htmls))
|
||||||
|
self._log_box.insertPlainText("\n")
|
||||||
|
self._log_box.ensureCursorVisible()
|
||||||
|
if error_seen and hasattr(self, "_status_label"):
|
||||||
|
self._status_label.setText("Status: error")
|
||||||
|
|
||||||
def _init_runtime_services(self) -> None:
|
def _init_runtime_services(self) -> None:
|
||||||
"""Initialize long-lived service objects used by mixins."""
|
"""Initialize long-lived service objects used by mixins."""
|
||||||
@@ -266,6 +324,10 @@ class AppWindow(
|
|||||||
def _init_capture_state(self) -> None:
|
def _init_capture_state(self) -> None:
|
||||||
"""Initialize one-shot capture and sequence-control flags."""
|
"""Initialize one-shot capture and sequence-control flags."""
|
||||||
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
|
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
|
||||||
|
# Guards the blocking per-combo capture against duplicate requests, and keeps
|
||||||
|
# the dialog's action buttons disabled until the post-capture input backlog
|
||||||
|
# is dropped (see AppWindowPreprocessMixin._begin/_end_preprocess_capture).
|
||||||
|
self._preprocess_capture_busy = False
|
||||||
self._resume_pipeline_after_capture = False
|
self._resume_pipeline_after_capture = False
|
||||||
self._single_capture_active = False
|
self._single_capture_active = False
|
||||||
self._single_capture_start_ns: int | None = None
|
self._single_capture_start_ns: int | None = None
|
||||||
@@ -551,20 +613,8 @@ class AppWindow(
|
|||||||
"""Return full chained traceback for error dialogs and log details."""
|
"""Return full chained traceback for error dialogs and log details."""
|
||||||
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
||||||
|
|
||||||
def _append_log_entry(
|
def _render_log_entry_html(self, level: str, text: str, details: str | None = None) -> str:
|
||||||
self,
|
"""Render one log entry as the panel's HTML block."""
|
||||||
level: str,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
details: str | None = None,
|
|
||||||
once_key: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Append formatted log entry with timestamp and optional details."""
|
|
||||||
if once_key is not None:
|
|
||||||
if once_key in self._logged_once_keys:
|
|
||||||
return
|
|
||||||
self._logged_once_keys.add(once_key)
|
|
||||||
|
|
||||||
level_upper = level.upper()
|
level_upper = level.upper()
|
||||||
palette = {
|
palette = {
|
||||||
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
|
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
|
||||||
@@ -586,16 +636,30 @@ class AppWindow(
|
|||||||
"<pre style='margin:3px 0 0 16px; color:"
|
"<pre style='margin:3px 0 0 16px; color:"
|
||||||
f"{detail_color};'>{html.escape(details)}</pre>"
|
f"{detail_color};'>{html.escape(details)}</pre>"
|
||||||
)
|
)
|
||||||
|
return "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
||||||
|
|
||||||
|
def _append_log_entry(
|
||||||
|
self,
|
||||||
|
level: str,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
details: str | None = None,
|
||||||
|
once_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append formatted log entry with timestamp and optional details."""
|
||||||
|
if once_key is not None:
|
||||||
|
if once_key in self._logged_once_keys:
|
||||||
|
return
|
||||||
|
self._logged_once_keys.add(once_key)
|
||||||
|
|
||||||
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
|
||||||
cursor = self._log_box.textCursor()
|
cursor = self._log_box.textCursor()
|
||||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||||
self._log_box.setTextCursor(cursor)
|
self._log_box.setTextCursor(cursor)
|
||||||
self._log_box.insertHtml(entry_html)
|
self._log_box.insertHtml(self._render_log_entry_html(level, text, details))
|
||||||
self._log_box.insertPlainText("\n")
|
self._log_box.insertPlainText("\n")
|
||||||
self._log_box.ensureCursorVisible()
|
self._log_box.ensureCursorVisible()
|
||||||
|
|
||||||
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
if level.upper() == "ERROR" and hasattr(self, "_status_label"):
|
||||||
self._status_label.setText("Status: error")
|
self._status_label.setText("Status: error")
|
||||||
|
|
||||||
def _on_log_level_selected(self, level_text: str) -> None:
|
def _on_log_level_selected(self, level_text: str) -> None:
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ _WEB_LIVE_SCHEMA = [
|
|||||||
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
|
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
|
||||||
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
|
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
|
||||||
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
|
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
|
||||||
("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")),
|
|
||||||
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
|
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
|
||||||
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
|
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
|
||||||
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
|
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
|
||||||
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
|
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
|
||||||
("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")),
|
("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")),
|
||||||
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
|
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
|
||||||
("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")),
|
("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")),
|
||||||
|
("gpr_object_approach_min_frames", "Detection", ("gpr",), _attr("_gpr_object_approach_min_frames")),
|
||||||
("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")),
|
("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")),
|
||||||
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
||||||
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
||||||
@@ -110,7 +110,9 @@ _WEB_DISPLAY_SCHEMA = [
|
|||||||
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
|
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
|
||||||
# "applies on Start" and editing them just updates the widget for the next start.
|
# "applies on Start" and editing them just updates the widget for the next start.
|
||||||
_WEB_STABLE_SCHEMA = [
|
_WEB_STABLE_SCHEMA = [
|
||||||
("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")),
|
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative
|
||||||
|
# permittivity is a legacy-GPR-only knob; BP ignores it.
|
||||||
|
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
|
||||||
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
|
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
|
||||||
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
|
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
|
||||||
]
|
]
|
||||||
@@ -479,14 +481,13 @@ class AppWindowLiveProcessingMixin:
|
|||||||
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
||||||
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
|
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
|
||||||
f"range_comp={self._gpr_range_comp_power.value():g}, "
|
f"range_comp={self._gpr_range_comp_power.value():g}, "
|
||||||
f"angle_comp={self._gpr_angle_comp_power.value():g}, "
|
f"object_min_frac={self._gpr_object_min_frac.value():g}, "
|
||||||
f"score_mode={self._gpr_score_mode.currentText()}, "
|
f"score_mode={self._gpr_score_mode.currentText()}, "
|
||||||
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
||||||
f"mean_count={self._gpr_background_mean_count.value()}, "
|
f"mean_count={self._gpr_background_mean_count.value()}, "
|
||||||
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
|
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
|
||||||
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
|
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
|
||||||
f"render_mode={self._gpr_render_mode.currentText()}, "
|
f"render_mode={self._gpr_render_mode.currentText()}, "
|
||||||
f"min_score={self._gpr_min_visible_score.value():g}, "
|
|
||||||
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
|
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
|
||||||
f"draw_top={self._gpr_draw_top_m_objects.value()})"
|
f"draw_top={self._gpr_draw_top_m_objects.value()})"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_min_depth_m,
|
self._gpr_min_depth_m,
|
||||||
self._gpr_max_depth_m,
|
self._gpr_max_depth_m,
|
||||||
self._gpr_range_comp_power,
|
self._gpr_range_comp_power,
|
||||||
self._gpr_angle_comp_power,
|
self._gpr_object_min_frac,
|
||||||
self._gpr_score_mode,
|
self._gpr_score_mode,
|
||||||
self._gpr_motion_mode,
|
self._gpr_motion_mode,
|
||||||
self._gpr_look_angle_deg,
|
self._gpr_look_angle_deg,
|
||||||
@@ -301,6 +301,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_speed_m_s,
|
self._gpr_speed_m_s,
|
||||||
self._gpr_max_detected_objects_to_draw,
|
self._gpr_max_detected_objects_to_draw,
|
||||||
self._gpr_draw_top_m_objects,
|
self._gpr_draw_top_m_objects,
|
||||||
|
self._gpr_object_approach_min_frames,
|
||||||
self._gpr_start_freq_mhz,
|
self._gpr_start_freq_mhz,
|
||||||
self._gpr_stop_freq_mhz,
|
self._gpr_stop_freq_mhz,
|
||||||
self._gpr_background_subtract_enabled,
|
self._gpr_background_subtract_enabled,
|
||||||
@@ -308,7 +309,6 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_remove_sidelobe_objects_enabled,
|
self._gpr_remove_sidelobe_objects_enabled,
|
||||||
self._gpr_imaging_plane_y_m,
|
self._gpr_imaging_plane_y_m,
|
||||||
self._gpr_render_mode,
|
self._gpr_render_mode,
|
||||||
self._gpr_min_visible_score,
|
|
||||||
self._gpr_visible_x_min_m,
|
self._gpr_visible_x_min_m,
|
||||||
self._gpr_visible_x_max_m,
|
self._gpr_visible_x_max_m,
|
||||||
self._gpr_visible_z_min_m,
|
self._gpr_visible_z_min_m,
|
||||||
@@ -459,7 +459,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
|
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
|
||||||
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
|
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
|
||||||
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
|
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
|
||||||
self._gpr_angle_comp_power.setValue(float(gui_state.processing.gpr.angle_comp_power))
|
self._gpr_object_min_frac.setValue(float(gui_state.processing.gpr.object_min_frac))
|
||||||
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
|
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
|
||||||
self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode)
|
self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode)
|
||||||
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
||||||
@@ -472,6 +472,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
int(gui_state.processing.gpr.max_detected_objects_to_draw)
|
int(gui_state.processing.gpr.max_detected_objects_to_draw)
|
||||||
)
|
)
|
||||||
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
|
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
|
||||||
|
self._gpr_object_approach_min_frames.setValue(int(gui_state.processing.gpr.object_approach_min_frames))
|
||||||
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
|
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
|
||||||
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
||||||
self._gpr_background_subtract_enabled.setChecked(
|
self._gpr_background_subtract_enabled.setChecked(
|
||||||
@@ -483,7 +484,6 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
)
|
)
|
||||||
self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m))
|
self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m))
|
||||||
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
|
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
|
||||||
self._gpr_min_visible_score.setValue(float(gui_state.processing.gpr.min_visible_score))
|
|
||||||
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
|
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
|
||||||
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
|
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
|
||||||
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
|
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
min_depth_m=2.0,
|
min_depth_m=2.0,
|
||||||
max_depth_m=14.0,
|
max_depth_m=14.0,
|
||||||
range_comp_power=0.1,
|
range_comp_power=0.1,
|
||||||
angle_comp_power=0.0,
|
object_min_frac=0.7,
|
||||||
score_mode="combined",
|
score_mode="combined",
|
||||||
motion_mode="int_minus",
|
motion_mode="int_minus",
|
||||||
look_angle_deg=0.0,
|
look_angle_deg=0.0,
|
||||||
@@ -250,6 +250,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
ignore_socket_speed_enabled=False,
|
ignore_socket_speed_enabled=False,
|
||||||
max_detected_objects_to_draw=5,
|
max_detected_objects_to_draw=5,
|
||||||
draw_top_m_objects=2,
|
draw_top_m_objects=2,
|
||||||
|
object_approach_min_frames=3,
|
||||||
start_freq_mhz=3000.0,
|
start_freq_mhz=3000.0,
|
||||||
stop_freq_mhz=6000.0,
|
stop_freq_mhz=6000.0,
|
||||||
background_subtract_enabled=True,
|
background_subtract_enabled=True,
|
||||||
@@ -257,7 +258,6 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
remove_sidelobe_objects_enabled=True,
|
remove_sidelobe_objects_enabled=True,
|
||||||
imaging_plane_y_m=0.0,
|
imaging_plane_y_m=0.0,
|
||||||
render_mode="heatmap",
|
render_mode="heatmap",
|
||||||
min_visible_score=0.0,
|
|
||||||
visible_x_min_m=default_gpr_x_min_m,
|
visible_x_min_m=default_gpr_x_min_m,
|
||||||
visible_x_max_m=default_gpr_x_max_m,
|
visible_x_max_m=default_gpr_x_max_m,
|
||||||
visible_z_min_m=0.0,
|
visible_z_min_m=0.0,
|
||||||
@@ -369,7 +369,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
min_depth_m=float(self._gpr_min_depth_m.value()),
|
min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||||
max_depth_m=float(self._gpr_max_depth_m.value()),
|
max_depth_m=float(self._gpr_max_depth_m.value()),
|
||||||
range_comp_power=float(self._gpr_range_comp_power.value()),
|
range_comp_power=float(self._gpr_range_comp_power.value()),
|
||||||
angle_comp_power=float(self._gpr_angle_comp_power.value()),
|
object_min_frac=float(self._gpr_object_min_frac.value()),
|
||||||
score_mode=self._gpr_score_mode.currentText(),
|
score_mode=self._gpr_score_mode.currentText(),
|
||||||
motion_mode=self._gpr_motion_mode.currentText(),
|
motion_mode=self._gpr_motion_mode.currentText(),
|
||||||
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
||||||
@@ -378,6 +378,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()),
|
ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()),
|
||||||
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
|
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
|
||||||
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
|
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
|
||||||
|
object_approach_min_frames=int(self._gpr_object_approach_min_frames.value()),
|
||||||
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||||
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
||||||
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||||
@@ -385,7 +386,6 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
||||||
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||||
render_mode=self._gpr_render_mode.currentText(),
|
render_mode=self._gpr_render_mode.currentText(),
|
||||||
min_visible_score=float(self._gpr_min_visible_score.value()),
|
|
||||||
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
|
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
|
||||||
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
|
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
|
||||||
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import pyqtgraph as pg
|
|||||||
|
|
||||||
from python_app.models.dataset_model import ResultCollection
|
from python_app.models.dataset_model import ResultCollection
|
||||||
from python_app.orchestration.gpr_locator import (
|
from python_app.orchestration.gpr_locator import (
|
||||||
apply_object_draw_limits as gpr_apply_object_draw_limits,
|
|
||||||
collection_payload_by_name as gpr_collection_payload_by_name,
|
collection_payload_by_name as gpr_collection_payload_by_name,
|
||||||
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
||||||
filter_object_rows as gpr_filter_object_rows,
|
filter_object_rows as gpr_filter_object_rows,
|
||||||
@@ -371,26 +370,6 @@ class AppWindowGprPlotMixin:
|
|||||||
return self._legacy_gpr_render_mode.currentText()
|
return self._legacy_gpr_render_mode.currentText()
|
||||||
return self._gpr_render_mode.currentText()
|
return self._gpr_render_mode.currentText()
|
||||||
|
|
||||||
def _gpr_locator_threshold(self) -> float:
|
|
||||||
"""Return object threshold using the active GPR mode's score semantics."""
|
|
||||||
if self._processing_mode.currentText() == "legacy_gpr":
|
|
||||||
return float(self._legacy_gpr_min_visible_pair_count.value())
|
|
||||||
return float(self._gpr_min_visible_score.value())
|
|
||||||
|
|
||||||
def _gpr_draw_limits(self) -> tuple[int, int] | None:
|
|
||||||
"""Return GPR object draw limits, or None for legacy GPR."""
|
|
||||||
if self._processing_mode.currentText() == "legacy_gpr":
|
|
||||||
return None
|
|
||||||
return (
|
|
||||||
int(self._gpr_max_detected_objects_to_draw.value()),
|
|
||||||
int(self._gpr_draw_top_m_objects.value()),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray:
|
|
||||||
"""Apply object count/top-M drawing rules to already-filtered rows."""
|
|
||||||
return gpr_apply_object_draw_limits(rows, limits)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||||
"""Return lower display bound, preserving surface markers only when surface is visible."""
|
"""Return lower display bound, preserving surface markers only when surface is visible."""
|
||||||
@@ -506,18 +485,24 @@ class AppWindowGprPlotMixin:
|
|||||||
return extract_gpr_object_rows(collection)
|
return extract_gpr_object_rows(collection)
|
||||||
|
|
||||||
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
||||||
"""Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits."""
|
"""Return the object rows to draw for the active GPR mode.
|
||||||
|
|
||||||
|
Coherent BP is already finalized by the processor (visible window + N/M draw
|
||||||
|
limits, no score threshold — exactly Horns_motion_3libre.py), so its rows are
|
||||||
|
drawn verbatim. Legacy GPR is still filtered here by its pair-count threshold
|
||||||
|
and the visible window.
|
||||||
|
"""
|
||||||
rows = self._gpr_object_rows(collection)
|
rows = self._gpr_object_rows(collection)
|
||||||
if rows.size == 0:
|
if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr":
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||||
return gpr_filter_object_rows(
|
return gpr_filter_object_rows(
|
||||||
rows,
|
rows,
|
||||||
min_score=self._gpr_locator_threshold(),
|
min_score=float(self._legacy_gpr_min_visible_pair_count.value()),
|
||||||
x_bounds=(x_min, x_max),
|
x_bounds=(x_min, x_max),
|
||||||
z_bounds=(z_min, z_max),
|
z_bounds=(z_min, z_max),
|
||||||
draw_limits=self._gpr_draw_limits(),
|
draw_limits=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QTimer
|
||||||
|
|
||||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||||
from python_app.gui.trace_png_export import export_trace_png
|
from python_app.gui.trace_png_export import export_trace_png
|
||||||
from python_app.orchestration.preprocess_assets import (
|
from python_app.orchestration.preprocess_assets import (
|
||||||
@@ -519,13 +521,23 @@ class AppWindowPreprocessMixin:
|
|||||||
if session is None:
|
if session is None:
|
||||||
self._show_error("No active capture sequence")
|
self._show_error("No active capture sequence")
|
||||||
return
|
return
|
||||||
|
# The capture blocks the event loop, so clicks made during it are delivered
|
||||||
|
# only after it finishes. `_begin_preprocess_capture` disables the action
|
||||||
|
# buttons for that whole window (re-enabled via a posted event), so a queued
|
||||||
|
# click lands on a disabled button instead of silently starting — and
|
||||||
|
# advancing the combo cursor of — another capture.
|
||||||
|
if not self._begin_preprocess_capture():
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
capture_result = session.capture_current_combo()
|
try:
|
||||||
except Exception as exc: # noqa: BLE001
|
capture_result = session.capture_current_combo()
|
||||||
self._on_capture_combo_failed(session, exc)
|
except Exception as exc: # noqa: BLE001
|
||||||
return
|
self._on_capture_combo_failed(session, exc)
|
||||||
self._record_preprocess_capture(session, capture_result)
|
return
|
||||||
|
self._record_preprocess_capture(session, capture_result)
|
||||||
|
finally:
|
||||||
|
self._end_preprocess_capture()
|
||||||
|
|
||||||
def _capture_all_remaining(self) -> None:
|
def _capture_all_remaining(self) -> None:
|
||||||
"""Capture all remaining combos for the active preprocess session."""
|
"""Capture all remaining combos for the active preprocess session."""
|
||||||
@@ -539,6 +551,8 @@ class AppWindowPreprocessMixin:
|
|||||||
details=self._capture_state_details(),
|
details=self._capture_state_details(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if not self._begin_preprocess_capture():
|
||||||
|
return
|
||||||
|
|
||||||
display_name = preprocess_asset_display_name(session.kind)
|
display_name = preprocess_asset_display_name(session.kind)
|
||||||
dialog = self._ensure_preprocess_dialog()
|
dialog = self._ensure_preprocess_dialog()
|
||||||
@@ -547,13 +561,40 @@ class AppWindowPreprocessMixin:
|
|||||||
f"{display_name} batch capture started: remaining="
|
f"{display_name} batch capture started: remaining="
|
||||||
f"{session.state().total_count - session.state().captured_count}"
|
f"{session.state().total_count - session.state().captured_count}"
|
||||||
)
|
)
|
||||||
while not session.is_complete():
|
try:
|
||||||
try:
|
while not session.is_complete():
|
||||||
capture_result = session.capture_current_combo()
|
try:
|
||||||
except Exception as exc: # noqa: BLE001
|
capture_result = session.capture_current_combo()
|
||||||
self._on_capture_combo_failed(session, exc)
|
except Exception as exc: # noqa: BLE001
|
||||||
return
|
self._on_capture_combo_failed(session, exc)
|
||||||
self._record_preprocess_capture(session, capture_result)
|
return
|
||||||
|
self._record_preprocess_capture(session, capture_result)
|
||||||
|
finally:
|
||||||
|
self._end_preprocess_capture()
|
||||||
|
|
||||||
|
def _begin_preprocess_capture(self) -> bool:
|
||||||
|
"""Mark a blocking combo capture as running; refuse when one already is.
|
||||||
|
|
||||||
|
Returns False for a duplicate request (e.g. a click delivered while an
|
||||||
|
error dialog inside a capture pumps the event loop).
|
||||||
|
"""
|
||||||
|
if self._preprocess_capture_busy:
|
||||||
|
self._log("Preprocess combo capture already in progress; ignoring duplicate request.")
|
||||||
|
return False
|
||||||
|
self._preprocess_capture_busy = True
|
||||||
|
# Disable the sequence action buttons for the whole blocked window.
|
||||||
|
self._update_capture_dialog_state()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _end_preprocess_capture(self) -> None:
|
||||||
|
"""Re-enable capture actions after the pending input backlog is discarded.
|
||||||
|
|
||||||
|
The zero-delay timer fires only after Qt has dispatched the window-system
|
||||||
|
events queued while the capture blocked the loop; those clicks hit the
|
||||||
|
still-disabled buttons and are dropped, then the buttons come back.
|
||||||
|
"""
|
||||||
|
self._preprocess_capture_busy = False
|
||||||
|
QTimer.singleShot(0, self._update_capture_dialog_state)
|
||||||
|
|
||||||
def _on_capture_combo_failed(
|
def _on_capture_combo_failed(
|
||||||
self,
|
self,
|
||||||
@@ -817,6 +858,10 @@ class AppWindowPreprocessMixin:
|
|||||||
and state.current_combo is not None
|
and state.current_combo is not None
|
||||||
),
|
),
|
||||||
variant_count=state.variant_count,
|
variant_count=state.variant_count,
|
||||||
|
# While a blocking capture is executing, every action stays disabled no
|
||||||
|
# matter what the session state allows: clicks queued during the freeze
|
||||||
|
# must land on disabled buttons (see `_end_preprocess_capture`).
|
||||||
|
actions_enabled=not self._preprocess_capture_busy,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cleanup_capture_session(self) -> None:
|
def _cleanup_capture_session(self) -> None:
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
|
|
||||||
gpr_defaults = owner._defaults_config.gpr
|
gpr_defaults = owner._defaults_config.gpr
|
||||||
|
|
||||||
|
# Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below).
|
||||||
|
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so it is not
|
||||||
|
# offered there. Applies on the next pipeline start (a run_config field).
|
||||||
owner._gpr_relative_permittivity = QDoubleSpinBox()
|
owner._gpr_relative_permittivity = QDoubleSpinBox()
|
||||||
owner._gpr_relative_permittivity.setDecimals(4)
|
owner._gpr_relative_permittivity.setDecimals(4)
|
||||||
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
|
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
|
||||||
owner._gpr_relative_permittivity.setSingleStep(0.05)
|
owner._gpr_relative_permittivity.setSingleStep(0.05)
|
||||||
|
owner._gpr_relative_permittivity.setToolTip("Applied on the next pipeline start (Save Config and restart).")
|
||||||
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
|
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
|
||||||
|
|
||||||
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
|
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
|
||||||
@@ -205,14 +209,13 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_common_page = _build_processing_mode_page(
|
owner._gpr_common_page = _build_processing_mode_page(
|
||||||
group,
|
group,
|
||||||
[
|
[
|
||||||
("Relative permittivity", owner._gpr_relative_permittivity),
|
|
||||||
("Tx geometry", owner._gpr_tx_geometry_input),
|
("Tx geometry", owner._gpr_tx_geometry_input),
|
||||||
("Rx geometry", owner._gpr_rx_geometry_input),
|
("Rx geometry", owner._gpr_rx_geometry_input),
|
||||||
],
|
],
|
||||||
split_index=1,
|
split_index=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry or permittivity changes: Save Config and restart the app")
|
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry changes: Save Config and restart the app")
|
||||||
owner._gpr_geometry_hint.setWordWrap(True)
|
owner._gpr_geometry_hint.setWordWrap(True)
|
||||||
owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint)
|
owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint)
|
||||||
|
|
||||||
@@ -240,11 +243,15 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_range_comp_power.setSingleStep(0.01)
|
owner._gpr_range_comp_power.setSingleStep(0.01)
|
||||||
owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power))
|
owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power))
|
||||||
|
|
||||||
owner._gpr_angle_comp_power = QDoubleSpinBox()
|
owner._gpr_object_min_frac = QDoubleSpinBox()
|
||||||
owner._gpr_angle_comp_power.setDecimals(3)
|
owner._gpr_object_min_frac.setDecimals(2)
|
||||||
owner._gpr_angle_comp_power.setRange(0.0, 5.0)
|
owner._gpr_object_min_frac.setRange(0.0, 1.0)
|
||||||
owner._gpr_angle_comp_power.setSingleStep(0.01)
|
owner._gpr_object_min_frac.setSingleStep(0.05)
|
||||||
owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power))
|
owner._gpr_object_min_frac.setToolTip(
|
||||||
|
"Object detection stops once a peak falls below this fraction of the global "
|
||||||
|
"maximum (Horns_motion_3libre.py BP_OBJECT_MIN_FRAC)."
|
||||||
|
)
|
||||||
|
owner._gpr_object_min_frac.setValue(float(gpr_live_defaults.object_min_frac))
|
||||||
|
|
||||||
owner._gpr_score_mode = QComboBox()
|
owner._gpr_score_mode = QComboBox()
|
||||||
owner._gpr_score_mode.addItems(["peak", "combined"])
|
owner._gpr_score_mode.addItems(["peak", "combined"])
|
||||||
@@ -300,6 +307,14 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_draw_top_m_objects.setRange(0, 10_000)
|
owner._gpr_draw_top_m_objects.setRange(0, 10_000)
|
||||||
owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects))
|
owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects))
|
||||||
|
|
||||||
|
owner._gpr_object_approach_min_frames = QSpinBox()
|
||||||
|
owner._gpr_object_approach_min_frames.setRange(1, 100)
|
||||||
|
owner._gpr_object_approach_min_frames.setToolTip(
|
||||||
|
"Show an object only after it persists as a motion-consistent track this many "
|
||||||
|
"consecutive frames (1 disables the approach filter)."
|
||||||
|
)
|
||||||
|
owner._gpr_object_approach_min_frames.setValue(int(gpr_live_defaults.object_approach_min_frames))
|
||||||
|
|
||||||
owner._gpr_start_freq_mhz = QDoubleSpinBox()
|
owner._gpr_start_freq_mhz = QDoubleSpinBox()
|
||||||
owner._gpr_start_freq_mhz.setDecimals(1)
|
owner._gpr_start_freq_mhz.setDecimals(1)
|
||||||
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
|
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
|
||||||
@@ -326,12 +341,6 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
|
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
|
||||||
owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode)
|
owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode)
|
||||||
|
|
||||||
owner._gpr_min_visible_score = QDoubleSpinBox()
|
|
||||||
owner._gpr_min_visible_score.setDecimals(2)
|
|
||||||
owner._gpr_min_visible_score.setRange(0.0, 1.0)
|
|
||||||
owner._gpr_min_visible_score.setSingleStep(0.05)
|
|
||||||
owner._gpr_min_visible_score.setValue(float(gpr_live_defaults.min_visible_score))
|
|
||||||
|
|
||||||
owner._gpr_visible_x_min_m = QDoubleSpinBox()
|
owner._gpr_visible_x_min_m = QDoubleSpinBox()
|
||||||
owner._gpr_visible_x_min_m.setDecimals(2)
|
owner._gpr_visible_x_min_m.setDecimals(2)
|
||||||
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
|
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
|
||||||
@@ -373,7 +382,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
("Min depth m", owner._gpr_min_depth_m),
|
("Min depth m", owner._gpr_min_depth_m),
|
||||||
("Max depth m", owner._gpr_max_depth_m),
|
("Max depth m", owner._gpr_max_depth_m),
|
||||||
("Range comp power", owner._gpr_range_comp_power),
|
("Range comp power", owner._gpr_range_comp_power),
|
||||||
("Angle comp power", owner._gpr_angle_comp_power),
|
("Object min frac", owner._gpr_object_min_frac),
|
||||||
("Score mode", owner._gpr_score_mode),
|
("Score mode", owner._gpr_score_mode),
|
||||||
("Motion mode", owner._gpr_motion_mode),
|
("Motion mode", owner._gpr_motion_mode),
|
||||||
("Look angle deg", owner._gpr_look_angle_deg),
|
("Look angle deg", owner._gpr_look_angle_deg),
|
||||||
@@ -381,9 +390,9 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_ignore_socket_speed_enabled,
|
owner._gpr_ignore_socket_speed_enabled,
|
||||||
("Speed m/s", owner._gpr_speed_m_s),
|
("Speed m/s", owner._gpr_speed_m_s),
|
||||||
("Render mode", owner._gpr_render_mode),
|
("Render mode", owner._gpr_render_mode),
|
||||||
("Min visible score", owner._gpr_min_visible_score),
|
|
||||||
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
|
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
|
||||||
("Draw top M objects", owner._gpr_draw_top_m_objects),
|
("Draw top M objects", owner._gpr_draw_top_m_objects),
|
||||||
|
("Approach min frames", owner._gpr_object_approach_min_frames),
|
||||||
("Start MHz", owner._gpr_start_freq_mhz),
|
("Start MHz", owner._gpr_start_freq_mhz),
|
||||||
("Stop MHz", owner._gpr_stop_freq_mhz),
|
("Stop MHz", owner._gpr_stop_freq_mhz),
|
||||||
("Imaging plane Y m", owner._gpr_imaging_plane_y_m),
|
("Imaging plane Y m", owner._gpr_imaging_plane_y_m),
|
||||||
@@ -533,6 +542,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._processing_mode_pages,
|
owner._processing_mode_pages,
|
||||||
[
|
[
|
||||||
("Config mode", owner._legacy_gpr_config_mode),
|
("Config mode", owner._legacy_gpr_config_mode),
|
||||||
|
("Relative permittivity", owner._gpr_relative_permittivity),
|
||||||
("Input positions", owner._legacy_gpr_input_positions_input),
|
("Input positions", owner._legacy_gpr_input_positions_input),
|
||||||
("Output positions", owner._legacy_gpr_output_positions_input),
|
("Output positions", owner._legacy_gpr_output_positions_input),
|
||||||
("Min depth m", owner._legacy_gpr_min_depth_m),
|
("Min depth m", owner._legacy_gpr_min_depth_m),
|
||||||
@@ -586,7 +596,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_angle_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_object_min_frac.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
@@ -600,9 +610,9 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
|
||||||
owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
|
||||||
owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
||||||
owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
||||||
|
owner._gpr_object_approach_min_frames.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
|
|||||||
@@ -354,8 +354,14 @@ class PreprocessDialog(QDialog):
|
|||||||
can_finalize: bool,
|
can_finalize: bool,
|
||||||
can_capture_all: bool,
|
can_capture_all: bool,
|
||||||
variant_count: int = 1,
|
variant_count: int = 1,
|
||||||
|
actions_enabled: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update sequence progress/status widgets."""
|
"""Update sequence progress/status widgets.
|
||||||
|
|
||||||
|
With ``actions_enabled=False`` the progress labels still update but every
|
||||||
|
sequence action button is kept disabled — used while a blocking capture
|
||||||
|
runs, so input queued during the freeze cannot trigger another action.
|
||||||
|
"""
|
||||||
if kind is None:
|
if kind is None:
|
||||||
self._active_kind_label.setText("<none>")
|
self._active_kind_label.setText("<none>")
|
||||||
self._progress_label.setText("0 / 0")
|
self._progress_label.setText("0 / 0")
|
||||||
@@ -368,13 +374,14 @@ class PreprocessDialog(QDialog):
|
|||||||
self._capture_all_button.setText("Capture All Remaining")
|
self._capture_all_button.setText("Capture All Remaining")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
actions_enabled = bool(actions_enabled)
|
||||||
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
|
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
|
||||||
self._active_kind_label.setText(active_label)
|
self._active_kind_label.setText(active_label)
|
||||||
self._progress_label.setText(f"{captured_count} / {total_count}")
|
self._progress_label.setText(f"{captured_count} / {total_count}")
|
||||||
self._undo_last_button.setEnabled(bool(can_undo))
|
self._undo_last_button.setEnabled(bool(can_undo) and actions_enabled)
|
||||||
self._save_sequence_button.setEnabled(bool(can_finalize))
|
self._save_sequence_button.setEnabled(bool(can_finalize) and actions_enabled)
|
||||||
self._capture_all_button.setEnabled(bool(can_capture_all))
|
self._capture_all_button.setEnabled(bool(can_capture_all) and actions_enabled)
|
||||||
self._abort_button.setEnabled(True)
|
self._abort_button.setEnabled(actions_enabled)
|
||||||
self._capture_all_button.setText("Capture All Remaining")
|
self._capture_all_button.setText("Capture All Remaining")
|
||||||
if next_input is None or next_output is None:
|
if next_input is None or next_output is None:
|
||||||
self._combo_label.setText("<complete>")
|
self._combo_label.setText("<complete>")
|
||||||
@@ -384,7 +391,7 @@ class PreprocessDialog(QDialog):
|
|||||||
if int(variant_count) > 1:
|
if int(variant_count) > 1:
|
||||||
combo_text += f" | radar configs={int(variant_count)}"
|
combo_text += f" | radar configs={int(variant_count)}"
|
||||||
self._combo_label.setText(combo_text)
|
self._combo_label.setText(combo_text)
|
||||||
self._capture_next_button.setEnabled(True)
|
self._capture_next_button.setEnabled(actions_enabled)
|
||||||
|
|
||||||
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
|
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
|
||||||
"""Replace combo-box choices for all preprocess assets."""
|
"""Replace combo-box choices for all preprocess assets."""
|
||||||
|
|||||||
@@ -86,12 +86,38 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
|||||||
"delay_time": variation.delay_time,
|
"delay_time": variation.delay_time,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_write_variation_session(variation)
|
||||||
return True
|
return True
|
||||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||||
finally:
|
finally:
|
||||||
controller.disconnect()
|
controller.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_variation_session(variation) -> None:
|
||||||
|
"""Freeze the variation's static temperature targets for the checker.
|
||||||
|
|
||||||
|
Best-effort: a failure to write the session snapshot must never abort the
|
||||||
|
acquisition setup, so any error is logged and swallowed.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from python_app.hardware_full.laser_control.monitoring.session import (
|
||||||
|
LaserVariationSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
LaserVariationSession(
|
||||||
|
variation_type=variation.variation_type,
|
||||||
|
target_temp1=variation.static_temp1,
|
||||||
|
target_temp2=variation.static_temp2,
|
||||||
|
tolerance_c=variation.temp_tolerance_c,
|
||||||
|
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
||||||
|
).save()
|
||||||
|
logger.debug("Wrote laser variation session snapshot for the temperature checker")
|
||||||
|
except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal
|
||||||
|
logger.warning("Failed to write laser variation session snapshot", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
||||||
laser = config.radar.laser_control
|
laser = config.radar.laser_control
|
||||||
if not laser.port:
|
if not laser.port:
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ import numpy as np
|
|||||||
FRAME_BYTES = 8
|
FRAME_BYTES = 8
|
||||||
MAIN_MARKER = 0x000A
|
MAIN_MARKER = 0x000A
|
||||||
REFERENCE_MARKER = 0x00A8
|
REFERENCE_MARKER = 0x00A8
|
||||||
|
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
|
||||||
|
# collector right after a sweep boundary to label the upcoming sweep with the RF
|
||||||
|
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
|
||||||
|
# a switch transition and must be dropped). Absent in the standalone/calibration
|
||||||
|
# collector, where every sweep carries no combo (``combo is None``).
|
||||||
|
COMBO_MARKER = 0x00C0
|
||||||
_BOUNDARY_STEP = 0xFFFF
|
_BOUNDARY_STEP = 0xFFFF
|
||||||
|
|
||||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||||
@@ -52,6 +58,8 @@ class RawSweep:
|
|||||||
steps: np.ndarray
|
steps: np.ndarray
|
||||||
main: np.ndarray
|
main: np.ndarray
|
||||||
reference: np.ndarray
|
reference: np.ndarray
|
||||||
|
combo: tuple[int, int] | None = None
|
||||||
|
dirty: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self) -> int:
|
def size(self) -> int:
|
||||||
@@ -66,13 +74,17 @@ class KamilAdcStreamParser:
|
|||||||
but holds no I/O and is cheap to unit-test.
|
but holds no I/O and is cheap to unit-test.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
|
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
self._aligned = False
|
self._aligned = False
|
||||||
self._main: dict[int, complex] = {}
|
self._main: dict[int, complex] = {}
|
||||||
self._reference: dict[int, complex] = {}
|
self._reference: dict[int, complex] = {}
|
||||||
|
# Combo tag for the sweep currently being accumulated (set by the combo
|
||||||
|
# frame right after each boundary; ``None`` in non-switch collector modes).
|
||||||
|
self._pending_combo: tuple[int, int] | None = None
|
||||||
|
self._pending_dirty = False
|
||||||
|
|
||||||
def feed(self, data: bytes) -> list[RawSweep]:
|
def feed(self, data: bytes) -> list[RawSweep]:
|
||||||
"""Append ``data`` and return any sweeps completed by it."""
|
"""Append ``data`` and return any sweeps completed by it."""
|
||||||
@@ -95,6 +107,10 @@ class KamilAdcStreamParser:
|
|||||||
self._main[step] = complex(real, imag)
|
self._main[step] = complex(real, imag)
|
||||||
elif marker == REFERENCE_MARKER:
|
elif marker == REFERENCE_MARKER:
|
||||||
self._reference[step] = complex(real, imag)
|
self._reference[step] = complex(real, imag)
|
||||||
|
elif marker == COMBO_MARKER:
|
||||||
|
# step = input_pos, real = output_pos, imag = dirty flag.
|
||||||
|
self._pending_combo = (int(step), int(real))
|
||||||
|
self._pending_dirty = imag != 0
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||||
@@ -107,6 +123,8 @@ class KamilAdcStreamParser:
|
|||||||
self._aligned = False
|
self._aligned = False
|
||||||
self._main.clear()
|
self._main.clear()
|
||||||
self._reference.clear()
|
self._reference.clear()
|
||||||
|
self._pending_combo = None
|
||||||
|
self._pending_dirty = False
|
||||||
|
|
||||||
def _align(self) -> bool:
|
def _align(self) -> bool:
|
||||||
"""Discard pre-roll up to and including the first sweep boundary.
|
"""Discard pre-roll up to and including the first sweep boundary.
|
||||||
@@ -122,6 +140,8 @@ class KamilAdcStreamParser:
|
|||||||
del self._buffer[: index + FRAME_BYTES]
|
del self._buffer[: index + FRAME_BYTES]
|
||||||
self._main.clear()
|
self._main.clear()
|
||||||
self._reference.clear()
|
self._reference.clear()
|
||||||
|
self._pending_combo = None
|
||||||
|
self._pending_dirty = False
|
||||||
self._aligned = True
|
self._aligned = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -130,12 +150,21 @@ class KamilAdcStreamParser:
|
|||||||
shared = sorted(self._main.keys() & self._reference.keys())
|
shared = sorted(self._main.keys() & self._reference.keys())
|
||||||
main = self._main
|
main = self._main
|
||||||
reference = self._reference
|
reference = self._reference
|
||||||
|
combo = self._pending_combo
|
||||||
|
dirty = self._pending_dirty
|
||||||
self._main = {}
|
self._main = {}
|
||||||
self._reference = {}
|
self._reference = {}
|
||||||
|
# The next sweep's combo is set by its own combo frame (right after this
|
||||||
|
# boundary); clear so a sweep without one reports combo=None rather than
|
||||||
|
# inheriting a stale tag.
|
||||||
|
self._pending_combo = None
|
||||||
|
self._pending_dirty = False
|
||||||
if not shared:
|
if not shared:
|
||||||
return None
|
return None
|
||||||
return RawSweep(
|
return RawSweep(
|
||||||
steps=np.asarray(shared, dtype=np.int32),
|
steps=np.asarray(shared, dtype=np.int32),
|
||||||
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||||
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||||
|
combo=combo,
|
||||||
|
dirty=dirty,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ _REJECT_LOG_EVERY = 50
|
|||||||
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
||||||
# the configured stop_timeout_s so a stop can never hang.
|
# the configured stop_timeout_s so a stop can never hang.
|
||||||
_STOP_KILL_GRACE_S = 0.5
|
_STOP_KILL_GRACE_S = 0.5
|
||||||
|
# Sweeps to skip after a Python-driven switch change before trusting a capture: one
|
||||||
|
# for the pre-switch sweep still in the mailbox, one for a possible transition
|
||||||
|
# straddler in flight. Calibration speed is not critical, so we err on safety.
|
||||||
|
_SWITCH_DRAIN_SWEEPS = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -54,6 +58,10 @@ class KamilAdcService:
|
|||||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
||||||
|
|
||||||
config: RunConfigModel
|
config: RunConfigModel
|
||||||
|
# When set, the collector is launched with ``config:<path>`` so it drives the RF
|
||||||
|
# switches itself (switch-aware mode) from this run_config.json. ``None`` keeps
|
||||||
|
# the standalone collector that streams a single channel (calibration / mock).
|
||||||
|
switch_config_path: str | None = None
|
||||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||||
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
|
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
|
||||||
@@ -64,9 +72,18 @@ class KamilAdcService:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> list[str]:
|
def command(self) -> list[str]:
|
||||||
"""External collector command, including the generated ``tty:`` argument."""
|
"""External collector command, including the generated ``tty:`` argument.
|
||||||
|
|
||||||
|
In switch-aware mode (``switch_config_path`` set) the collector also gets
|
||||||
|
``config:<path>`` so it reads the switch/combo configuration and drives the
|
||||||
|
switches in lock-step with the sweeps.
|
||||||
|
"""
|
||||||
adc = self.config.radar.kamil_adc
|
adc = self.config.radar.kamil_adc
|
||||||
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
|
cmd = [str(self._resolve_executable()), *adc.args]
|
||||||
|
if self.switch_config_path is not None:
|
||||||
|
cmd.append(f"config:{self.switch_config_path}")
|
||||||
|
cmd.append(f"tty:{adc.tty_path}")
|
||||||
|
return cmd
|
||||||
|
|
||||||
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
||||||
"""Launch the collector and start the TTY reader thread.
|
"""Launch the collector and start the TTY reader thread.
|
||||||
@@ -122,12 +139,17 @@ class KamilAdcService:
|
|||||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
||||||
raise RuntimeError("Kamil ADC device limits are not available")
|
raise RuntimeError("Kamil ADC device limits are not available")
|
||||||
|
|
||||||
def acquire(self) -> SweepResult:
|
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
|
||||||
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
||||||
|
|
||||||
Sweeps whose floated frequency range does not span the configured band are
|
Sweeps whose floated frequency range does not span the configured band are
|
||||||
rejected and the next sweep is read, until one passes or the sweep timeout
|
rejected and the next sweep is read, until one passes or the sweep timeout
|
||||||
elapses (which then surfaces as a :class:`TimeoutError`).
|
elapses (which then surfaces as a :class:`TimeoutError`).
|
||||||
|
|
||||||
|
When ``combo`` is given (switch-aware mode), only the clean sweep captured
|
||||||
|
under that switch combination is returned; the collector drives the switches
|
||||||
|
and tags each sweep. When ``None`` (calibration / non-switch mode), the
|
||||||
|
single newest sweep is returned regardless of combination.
|
||||||
"""
|
"""
|
||||||
if self._processor is None:
|
if self._processor is None:
|
||||||
raise RuntimeError("Kamil ADC service is not configured")
|
raise RuntimeError("Kamil ADC service is not configured")
|
||||||
@@ -147,7 +169,10 @@ class KamilAdcService:
|
|||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
||||||
)
|
)
|
||||||
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)
|
s21 = self._processor.process(raw.main, raw.reference)
|
||||||
if s21 is not None:
|
if s21 is not None:
|
||||||
return SweepResult(
|
return SweepResult(
|
||||||
@@ -159,6 +184,31 @@ class KamilAdcService:
|
|||||||
)
|
)
|
||||||
self._log_rejected_sweep(raw)
|
self._log_rejected_sweep(raw)
|
||||||
|
|
||||||
|
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
|
||||||
|
"""Discard sweeps captured before / across a just-applied switch change.
|
||||||
|
|
||||||
|
The collector free-runs, so right after the RF switches move the reader
|
||||||
|
still holds a sweep captured in the *previous* combination, and a sweep that
|
||||||
|
straddles the transition may still be in flight. Without this, the next
|
||||||
|
:meth:`acquire` would return that stale data and the capture would be
|
||||||
|
attributed to the wrong combination (an off-by-one across the sequence).
|
||||||
|
|
||||||
|
Block until ``sweeps`` freshly-published sweeps have gone by, so the next
|
||||||
|
:meth:`acquire` returns a sweep captured entirely in the new switch state.
|
||||||
|
Used by the Python-driven calibration capture, where the collector does not
|
||||||
|
tag sweeps; the switch-aware collector path handles this with combo tags
|
||||||
|
instead. No-op when the service is not open.
|
||||||
|
"""
|
||||||
|
if self._reader is None:
|
||||||
|
return
|
||||||
|
target = self._reader.published_count + max(1, int(sweeps))
|
||||||
|
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
||||||
|
while self._reader.published_count < target:
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
|
||||||
|
raise_if_process_exited(self._process)
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
def read_raw_sweep(self) -> RawSweep:
|
def read_raw_sweep(self) -> RawSweep:
|
||||||
"""Return the next raw (main, reference) sweep without any processing.
|
"""Return the next raw (main, reference) sweep without any processing.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class KamilAdcTtyReader:
|
|||||||
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
||||||
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
||||||
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
||||||
|
# Latest clean sweep per switch combination, for the switch-aware collector.
|
||||||
|
# read_sweep() ignores this and serves the single newest sweep (calibration /
|
||||||
|
# non-switch mode); read_sweep_for() serves a specific combination.
|
||||||
|
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
|
||||||
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||||
_published_count: int = field(init=False, default=0, repr=False)
|
_published_count: int = field(init=False, default=0, repr=False)
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ class KamilAdcTtyReader:
|
|||||||
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
self._latest_sweep = None
|
self._latest_sweep = None
|
||||||
|
self._combo_slots = {}
|
||||||
self._reader_error = None
|
self._reader_error = None
|
||||||
self._published_count = 0
|
self._published_count = 0
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
@@ -89,6 +94,7 @@ class KamilAdcTtyReader:
|
|||||||
finally:
|
finally:
|
||||||
self._fd = None
|
self._fd = None
|
||||||
self._latest_sweep = None
|
self._latest_sweep = None
|
||||||
|
self._combo_slots = {}
|
||||||
self._reader_error = None
|
self._reader_error = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -131,6 +137,39 @@ class KamilAdcTtyReader:
|
|||||||
)
|
)
|
||||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||||
|
|
||||||
|
def read_sweep_for(
|
||||||
|
self,
|
||||||
|
combo: tuple[int, int],
|
||||||
|
*,
|
||||||
|
timeout_s: float,
|
||||||
|
process: subprocess.Popen[bytes] | None = None,
|
||||||
|
) -> RawSweep:
|
||||||
|
"""Wait for and return the latest clean sweep for ``combo``.
|
||||||
|
|
||||||
|
Used in switch-aware mode, where the collector drives the switches and tags
|
||||||
|
each sweep with its combination. Only clean sweeps are delivered (the reader
|
||||||
|
thread drops the dirty ones); the slot is consumed on read so each caller
|
||||||
|
gets a fresh capture. Raises like :meth:`read_sweep`.
|
||||||
|
"""
|
||||||
|
if self._thread is None:
|
||||||
|
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||||
|
deadline = time.monotonic() + float(timeout_s)
|
||||||
|
with self._mailbox_cv:
|
||||||
|
while True:
|
||||||
|
sweep = self._combo_slots.pop(combo, None)
|
||||||
|
if sweep is not None:
|
||||||
|
return sweep
|
||||||
|
if self._reader_error is not None:
|
||||||
|
raise self._reader_error
|
||||||
|
raise_if_process_exited(process)
|
||||||
|
remaining_s = deadline - time.monotonic()
|
||||||
|
if remaining_s <= 0.0:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
|
||||||
|
f"after {float(timeout_s):.3f}s"
|
||||||
|
)
|
||||||
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Reader-thread internals
|
# Reader-thread internals
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -177,11 +216,22 @@ class KamilAdcTtyReader:
|
|||||||
return chunk
|
return chunk
|
||||||
|
|
||||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||||
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
|
"""Publish a completed sweep to the mailbox(es), waking any waiter.
|
||||||
|
|
||||||
|
Dirty sweeps (those that straddled a switch transition) are counted but not
|
||||||
|
delivered: the collector re-takes that combination on the next sweep. Clean
|
||||||
|
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
|
||||||
|
only update the single newest-sweep mailbox that read_sweep() serves.
|
||||||
|
"""
|
||||||
with self._mailbox_cv:
|
with self._mailbox_cv:
|
||||||
self._latest_sweep = sweep
|
|
||||||
self._published_count += 1
|
self._published_count += 1
|
||||||
self._mailbox_cv.notify()
|
if sweep.dirty:
|
||||||
|
self._mailbox_cv.notify_all()
|
||||||
|
return
|
||||||
|
self._latest_sweep = sweep
|
||||||
|
if sweep.combo is not None:
|
||||||
|
self._combo_slots[sweep.combo] = sweep
|
||||||
|
self._mailbox_cv.notify_all()
|
||||||
|
|
||||||
def _publish_error(self, exc: Exception) -> None:
|
def _publish_error(self, exc: Exception) -> None:
|
||||||
"""Record ``exc`` as the reader fault and wake any waiter."""
|
"""Record ``exc`` as the reader fault and wake any waiter."""
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Контроль температуры при вариации тока лазера
|
||||||
|
|
||||||
|
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
|
||||||
|
**вариации тока лазера 1** (`CHANGE_CURRENT_LD1`). Пока плата гоняет свип тока,
|
||||||
|
температуры лазеров должны оставаться на заданных статичных уставках. Эти модули
|
||||||
|
раз в свип считывают реальную температуру и предупреждают, если она разошлась с
|
||||||
|
целью.
|
||||||
|
|
||||||
|
## Зачем это нужно
|
||||||
|
|
||||||
|
При запуске вариации тока из GUI изменённые значения температуры могут фактически
|
||||||
|
не дойти до цели — реальная температура остаётся прежней, и измерение становится
|
||||||
|
некорректным. Плата после старта задачи гоняет свип **автономно** и никак не
|
||||||
|
сигнализирует, что уставка не достигнута. Эти модули закрывают пробел: независимо
|
||||||
|
опрашивают плату и валидируют температуру относительно уставок, зафиксированных
|
||||||
|
**в момент старта вариации**.
|
||||||
|
|
||||||
|
> ⚠️ В прошивке реализована только **вариация тока** (`CHANGE_CURRENT_LD1`).
|
||||||
|
> Вариация температуры не поддерживается и в этот API не заложена.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
[starter] ── TASK_ENABLE ──► плата кратко открыл порт, послал, закрыл
|
||||||
|
│ пишет session.json (target temp1/2, tolerance, variation_type)
|
||||||
|
▼
|
||||||
|
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
|
||||||
|
│ раз в свип: get_measurements()
|
||||||
|
│ дописывает строку в readings.jsonl (seq, temp1, temp2, temp_ext, I1, I2)
|
||||||
|
▼
|
||||||
|
[checker] читает session.json + tail readings.jsonl
|
||||||
|
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Порт лазера эксклюзивен.** `starter` трогает его кратко, затем `monitor`
|
||||||
|
владеет им всё время. `checker` порт не трогает вовсе — читает только файлы.
|
||||||
|
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
|
||||||
|
останавливаются и перезапускаются независимо, без рукопожатия.
|
||||||
|
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
|
||||||
|
термисторам `temp_ext*`), каждый со своим допуском (по умолчанию `0.03 °C`).
|
||||||
|
|
||||||
|
## Быстрый старт (CLI, два процесса)
|
||||||
|
|
||||||
|
Терминал 1 — стартовать вариацию и мониторить температуру:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_monitor \
|
||||||
|
--config run_config.json \
|
||||||
|
--start
|
||||||
|
```
|
||||||
|
|
||||||
|
Терминал 2 — валидировать температуру и печатать предупреждения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_checker
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример вывода чекера при расхождении и возврате в допуск:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING laser_temp_checker: Laser 1 temperature off target: measured 28.100 °C,
|
||||||
|
target 28.000 °C, Δ=+0.100 °C exceeds tolerance ±0.030 °C [seq=1]
|
||||||
|
INFO laser_temp_checker: Laser 1 temperature back within tolerance:
|
||||||
|
28.000 °C (target 28.000, |Δ|=0.000 ≤ 0.030) [seq=2]
|
||||||
|
```
|
||||||
|
|
||||||
|
Остановка — `Ctrl+C` (SIGINT) в любом из процессов.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
|
||||||
|
использует блок `variation` и новое поле `temp_tolerance_c`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "kamil_adc",
|
||||||
|
"laser_control": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": "/dev/ttyUSB0",
|
||||||
|
"mode": "variation",
|
||||||
|
"pi_coeff1_p": 2560,
|
||||||
|
"pi_coeff1_i": 128,
|
||||||
|
"pi_coeff2_p": 2560,
|
||||||
|
"pi_coeff2_i": 128,
|
||||||
|
"variation": {
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"static_temp1": 28.0,
|
||||||
|
"static_temp2": 28.9,
|
||||||
|
"static_current1": 33.0,
|
||||||
|
"static_current2": 35.0,
|
||||||
|
"min_value": 33.0,
|
||||||
|
"max_value": 60.0,
|
||||||
|
"step": 0.05,
|
||||||
|
"time_step": 50,
|
||||||
|
"delay_time": 10,
|
||||||
|
"temp_tolerance_c": 0.03
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые поля для мониторинга:
|
||||||
|
|
||||||
|
| Поле | Смысл |
|
||||||
|
|---|---|
|
||||||
|
| `port` | Серийный порт лазерной платы (пусто → автоопределение) |
|
||||||
|
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
|
||||||
|
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
|
||||||
|
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
|
||||||
|
| `temp_tolerance_c` | Допуск сверки, °C (по умолчанию `0.03`) |
|
||||||
|
|
||||||
|
## Опции CLI
|
||||||
|
|
||||||
|
### `laser_temp_monitor`
|
||||||
|
|
||||||
|
| Аргумент | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `--config` | — (обязателен) | Путь к `run_config.json` |
|
||||||
|
| `--start` | выкл. | Послать `CHANGE_CURRENT_LD1` перед мониторингом и записать сессию |
|
||||||
|
| `--strategy` | `computed` | `computed` (раз в свип) или `interval:<ms>` (фикс. период) |
|
||||||
|
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Куда дописывать показания |
|
||||||
|
| `--session` | `<tmp>/laser_variation_session.json` | Куда писать снимок сессии (с `--start`) |
|
||||||
|
|
||||||
|
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Фиксированный период вместо расчётного (напр. раз в 500 мс):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m python_app.scripts.laser_temp_monitor \
|
||||||
|
--config run_config.json --strategy interval:500
|
||||||
|
```
|
||||||
|
|
||||||
|
### `laser_temp_checker`
|
||||||
|
|
||||||
|
| Аргумент | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `--session` | `<tmp>/laser_variation_session.json` | Снимок с целями и допуском |
|
||||||
|
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Какой канал показаний тайлить |
|
||||||
|
| `--tolerance` | из сессии | Переопределить допуск, °C |
|
||||||
|
| `--reminder-every` | `0` (выкл.) | Повторять предупреждение каждые N показаний, пока вне допуска |
|
||||||
|
| `--from-start` | выкл. | Проверить весь файл показаний, а не только новые строки |
|
||||||
|
|
||||||
|
Разные пути для нескольких одновременных прогонов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# монитор
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
|
||||||
|
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
|
||||||
|
# чекер
|
||||||
|
python -m python_app.scripts.laser_temp_checker \
|
||||||
|
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Интеграция с пайплайном Kamil ADC
|
||||||
|
|
||||||
|
Когда вариацию стартует штатный пайплайн
|
||||||
|
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
|
||||||
|
`session.json` пишется автоматически. Достаточно запустить только чекер
|
||||||
|
(и, при желании, монитор без `--start`, чтобы он опрашивал плату). Так консоль
|
||||||
|
получит предупреждения о рассинхроне температуры прямо во время захвата.
|
||||||
|
|
||||||
|
## Встраивание в свой код (без CLI)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import threading
|
||||||
|
from python_app.hardware_full.laser_control.controller import LaserController
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
LaserTemperatureMonitor, LaserTemperatureChecker, LaserVariationSession,
|
||||||
|
ReadingWriter, ReadingReader, resolve_period_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Зафиксировать цели при старте вариации
|
||||||
|
session = LaserVariationSession(
|
||||||
|
variation_type="CHANGE_CURRENT_LD1",
|
||||||
|
target_temp1=28.0, target_temp2=28.9, tolerance_c=0.03,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Монитор (в проде controller — реальный LaserController)
|
||||||
|
period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
|
||||||
|
time_step_us=50, delay_time_ms=10)
|
||||||
|
stop = threading.Event()
|
||||||
|
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
|
||||||
|
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
|
||||||
|
threading.Thread(target=monitor.run, args=(stop,), daemon=True).start()
|
||||||
|
|
||||||
|
# 3. Чекер: тайлить показания и валидировать оба лазера
|
||||||
|
checker = LaserTemperatureChecker.from_session(session)
|
||||||
|
reader = ReadingReader("readings.jsonl")
|
||||||
|
while not stop.is_set():
|
||||||
|
for reading in reader.poll():
|
||||||
|
checker.process(reading) # печатает WARNING при |Δ| > tolerance
|
||||||
|
stop.wait(0.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
`LaserTemperatureChecker.evaluate(reading)` возвращает список
|
||||||
|
`LaserDeviation` (по лазеру: измеренное, цель, Δ, в допуске ли) без логирования —
|
||||||
|
удобно для собственной обработки/накопления статистики.
|
||||||
|
|
||||||
|
## Формат IPC-файлов
|
||||||
|
|
||||||
|
`session.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"target_temp1": 28.0,
|
||||||
|
"target_temp2": 28.9,
|
||||||
|
"tolerance_c": 0.03,
|
||||||
|
"started_at_iso": "2026-07-27T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`readings.jsonl` (по одной строке-объекту на свип):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"seq":0,"mono_ns":123456789,"temp1":28.0,"temp2":28.9,"temp_ext1":22.0,"temp_ext2":23.0,"current1":33.0,"current2":35.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как определяется «раз в свип»
|
||||||
|
|
||||||
|
Плата не отдаёт явную границу свипа, поэтому период оценивается из параметров:
|
||||||
|
|
||||||
|
```
|
||||||
|
num_steps = round(|max_value - min_value| / step) + 1
|
||||||
|
per_point_s = delay_time / 1000 + time_step / 1_000_000
|
||||||
|
sweep_period = num_steps × per_point_s
|
||||||
|
```
|
||||||
|
|
||||||
|
Монитор публикует одно показание за такой период. Если нужен другой темп —
|
||||||
|
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
|
||||||
|
семантика не гарантирована, поэтому для тайминга он не используется.)
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest python_app/tests/test_laser_temp_monitoring.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
|
||||||
|
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
|
||||||
|
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Laser current-variation temperature monitoring and validation.
|
||||||
|
|
||||||
|
Three decoupled pieces connected via IPC files:
|
||||||
|
- :class:`LaserVariationSession` — target setpoints + tolerance frozen at start.
|
||||||
|
- :class:`LaserTemperatureMonitor` — polls the board once per sweep, publishes.
|
||||||
|
- :class:`LaserTemperatureChecker` — validates published readings, warns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .checker import LaserDeviation, LaserTemperatureChecker
|
||||||
|
from .monitor import (
|
||||||
|
LaserTemperatureMonitor,
|
||||||
|
compute_sweep_period_s,
|
||||||
|
resolve_period_s,
|
||||||
|
)
|
||||||
|
from .readings_channel import ReadingReader, ReadingWriter, TemperatureReading
|
||||||
|
from .session import (
|
||||||
|
DEFAULT_READINGS_PATH,
|
||||||
|
DEFAULT_SESSION_PATH,
|
||||||
|
DEFAULT_TOLERANCE_C,
|
||||||
|
LaserVariationSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LaserDeviation",
|
||||||
|
"LaserTemperatureChecker",
|
||||||
|
"LaserTemperatureMonitor",
|
||||||
|
"compute_sweep_period_s",
|
||||||
|
"resolve_period_s",
|
||||||
|
"ReadingReader",
|
||||||
|
"ReadingWriter",
|
||||||
|
"TemperatureReading",
|
||||||
|
"DEFAULT_READINGS_PATH",
|
||||||
|
"DEFAULT_SESSION_PATH",
|
||||||
|
"DEFAULT_TOLERANCE_C",
|
||||||
|
"LaserVariationSession",
|
||||||
|
]
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Independent laser temperature checker.
|
||||||
|
|
||||||
|
Reads the target setpoints frozen at variation start (:class:`LaserVariationSession`)
|
||||||
|
and validates each published :class:`TemperatureReading` against them. Both lasers
|
||||||
|
are checked independently: ``temp1`` against ``target_temp1`` and ``temp2`` against
|
||||||
|
``target_temp2``. When a laser's measured temperature deviates from its target by
|
||||||
|
more than the tolerance (default 0.03 °C), a warning is printed to the console.
|
||||||
|
|
||||||
|
Runs as its own process (see ``scripts/laser_temp_checker.py``), reading the JSONL
|
||||||
|
readings channel — it never touches the serial port, so it is fully independent of
|
||||||
|
the monitor and can be started, stopped, or restarted at any time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .readings_channel import TemperatureReading
|
||||||
|
from .session import DEFAULT_TOLERANCE_C, LaserVariationSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Only a deviation strictly greater than the tolerance warns; this epsilon keeps a
|
||||||
|
# value the user intends to be exactly at the tolerance from tripping on float error
|
||||||
|
# (e.g. 28.03 - 28.00 == 0.030000000000001 in IEEE-754).
|
||||||
|
_FLOAT_EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaserDeviation:
|
||||||
|
"""Result of comparing one laser's measured temperature to its target."""
|
||||||
|
|
||||||
|
laser: int # 1 or 2
|
||||||
|
seq: int
|
||||||
|
measured: float
|
||||||
|
target: float
|
||||||
|
delta: float # measured - target, °C
|
||||||
|
within_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LaserTemperatureChecker:
|
||||||
|
"""Validates readings against per-laser targets and warns on mismatch.
|
||||||
|
|
||||||
|
Anti-spam: a laser's ok↔mismatch transitions are logged once; while a laser
|
||||||
|
stays out of tolerance, a reminder is emitted only every ``reminder_every``
|
||||||
|
readings (0 disables reminders). State is tracked independently per laser, so
|
||||||
|
a persistent laser-1 fault never suppresses a fresh laser-2 warning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
target_temp1: float,
|
||||||
|
target_temp2: float,
|
||||||
|
tolerance_c: float = DEFAULT_TOLERANCE_C,
|
||||||
|
reminder_every: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.target_temp1 = float(target_temp1)
|
||||||
|
self.target_temp2 = float(target_temp2)
|
||||||
|
self.tolerance_c = float(tolerance_c)
|
||||||
|
self.reminder_every = int(reminder_every)
|
||||||
|
# Per-laser state: mismatch flag + readings seen since the last log.
|
||||||
|
self._mismatch = {1: False, 2: False}
|
||||||
|
self._since_log = {1: 0, 2: 0}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_session(
|
||||||
|
cls, session: LaserVariationSession, reminder_every: int = 0
|
||||||
|
) -> "LaserTemperatureChecker":
|
||||||
|
return cls(
|
||||||
|
target_temp1=session.target_temp1,
|
||||||
|
target_temp2=session.target_temp2,
|
||||||
|
tolerance_c=session.tolerance_c,
|
||||||
|
reminder_every=reminder_every,
|
||||||
|
)
|
||||||
|
|
||||||
|
def evaluate(self, reading: TemperatureReading) -> List[LaserDeviation]:
|
||||||
|
"""Compute per-laser deviations without logging (pure)."""
|
||||||
|
return [
|
||||||
|
self._deviation(1, reading.seq, reading.temp1, self.target_temp1),
|
||||||
|
self._deviation(2, reading.seq, reading.temp2, self.target_temp2),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process(self, reading: TemperatureReading) -> List[LaserDeviation]:
|
||||||
|
"""Evaluate a reading and emit console warnings, honouring anti-spam.
|
||||||
|
|
||||||
|
Returns the deviations for which a warning/reminder was emitted this call
|
||||||
|
(empty when both lasers are within tolerance and unchanged).
|
||||||
|
"""
|
||||||
|
warned: List[LaserDeviation] = []
|
||||||
|
for dev in self.evaluate(reading):
|
||||||
|
if self._should_warn(dev):
|
||||||
|
self._warn(dev)
|
||||||
|
warned.append(dev)
|
||||||
|
return warned
|
||||||
|
|
||||||
|
def _deviation(self, laser: int, seq: int, measured: float, target: float) -> LaserDeviation:
|
||||||
|
delta = measured - target
|
||||||
|
return LaserDeviation(
|
||||||
|
laser=laser,
|
||||||
|
seq=seq,
|
||||||
|
measured=measured,
|
||||||
|
target=target,
|
||||||
|
delta=delta,
|
||||||
|
within_tolerance=abs(delta) <= self.tolerance_c + _FLOAT_EPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_warn(self, dev: LaserDeviation) -> bool:
|
||||||
|
laser = dev.laser
|
||||||
|
if not dev.within_tolerance:
|
||||||
|
if not self._mismatch[laser]:
|
||||||
|
# Fresh ok -> mismatch transition: always warn.
|
||||||
|
self._mismatch[laser] = True
|
||||||
|
self._since_log[laser] = 0
|
||||||
|
return True
|
||||||
|
# Still out of tolerance: warn again only every reminder_every readings.
|
||||||
|
self._since_log[laser] += 1
|
||||||
|
if self.reminder_every > 0 and self._since_log[laser] >= self.reminder_every:
|
||||||
|
self._since_log[laser] = 0
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# Within tolerance: log a recovery once, then stay quiet.
|
||||||
|
if self._mismatch[laser]:
|
||||||
|
self._mismatch[laser] = False
|
||||||
|
self._since_log[laser] = 0
|
||||||
|
logger.info(
|
||||||
|
"Laser %d temperature back within tolerance: %.3f °C "
|
||||||
|
"(target %.3f, |Δ|=%.3f ≤ %.3f) [seq=%d]",
|
||||||
|
laser, dev.measured, dev.target, abs(dev.delta), self.tolerance_c, dev.seq,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _warn(self, dev: LaserDeviation) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"Laser %d temperature off target: measured %.3f °C, target %.3f °C, "
|
||||||
|
"Δ=%+.3f °C exceeds tolerance ±%.3f °C [seq=%d]",
|
||||||
|
dev.laser, dev.measured, dev.target, dev.delta, self.tolerance_c, dev.seq,
|
||||||
|
)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Independent laser temperature monitor.
|
||||||
|
|
||||||
|
Owns a :class:`LaserController` connection and, once per current-variation sweep,
|
||||||
|
polls the board for a measurement and publishes it to a JSONL readings channel.
|
||||||
|
The board runs the current sweep autonomously after ``TASK_ENABLE``; the monitor
|
||||||
|
only reads the "last data point" via ``TRANS_ENABLE`` — exactly like the original
|
||||||
|
RadioPhotonic PC software's polling loop, but decoupled and headless.
|
||||||
|
|
||||||
|
Runs as its own process (see ``scripts/laser_temp_monitor.py``) so it is fully
|
||||||
|
independent of both the acquisition pipeline and the temperature checker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
from .readings_channel import ReadingWriter, TemperatureReading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _MeasurementSource(Protocol):
|
||||||
|
"""Minimal controller surface the monitor depends on (eases testing)."""
|
||||||
|
|
||||||
|
def get_measurements(self) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sweep_period_s(
|
||||||
|
min_value: float,
|
||||||
|
max_value: float,
|
||||||
|
step: float,
|
||||||
|
time_step_us: float,
|
||||||
|
delay_time_ms: float,
|
||||||
|
) -> float:
|
||||||
|
"""Estimate the duration of one min→max current sweep, in seconds.
|
||||||
|
|
||||||
|
``num_steps = round(|max - min| / step) + 1`` points, each taking roughly the
|
||||||
|
inter-point delay plus the discretisation time. The board gives no explicit
|
||||||
|
end-of-sweep marker, so this computed period is how "once per sweep" is timed
|
||||||
|
by default.
|
||||||
|
"""
|
||||||
|
if step <= 0:
|
||||||
|
raise ValueError(f"step must be > 0, got {step}")
|
||||||
|
span = abs(max_value - min_value)
|
||||||
|
num_steps = round(span / step) + 1
|
||||||
|
per_point_s = delay_time_ms / 1000.0 + time_step_us / 1_000_000.0
|
||||||
|
return num_steps * per_point_s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_period_s(
|
||||||
|
strategy: str,
|
||||||
|
*,
|
||||||
|
min_value: float,
|
||||||
|
max_value: float,
|
||||||
|
step: float,
|
||||||
|
time_step_us: float,
|
||||||
|
delay_time_ms: float,
|
||||||
|
) -> float:
|
||||||
|
"""Turn a strategy string into a concrete per-reading period in seconds.
|
||||||
|
|
||||||
|
Supported strategies:
|
||||||
|
- ``"computed"`` — one reading per estimated sweep duration (default).
|
||||||
|
- ``"interval:<ms>"`` — a fixed period of ``<ms>`` milliseconds.
|
||||||
|
"""
|
||||||
|
if strategy == "computed":
|
||||||
|
return compute_sweep_period_s(min_value, max_value, step, time_step_us, delay_time_ms)
|
||||||
|
if strategy.startswith("interval:"):
|
||||||
|
try:
|
||||||
|
ms = float(strategy.split(":", 1)[1])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid interval strategy {strategy!r}") from exc
|
||||||
|
if ms <= 0:
|
||||||
|
raise ValueError(f"interval must be > 0 ms, got {ms}")
|
||||||
|
return ms / 1000.0
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown strategy {strategy!r}; expected 'computed' or 'interval:<ms>'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaserTemperatureMonitor:
|
||||||
|
"""Polls a laser board once per sweep and publishes temperature readings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
controller: object exposing ``get_measurements()`` (a real
|
||||||
|
:class:`LaserController` in production, a fake in tests).
|
||||||
|
writer: destination channel implementing ``write(TemperatureReading)``.
|
||||||
|
period_s: seconds between readings (see :func:`resolve_period_s`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
controller: _MeasurementSource
|
||||||
|
writer: ReadingWriter
|
||||||
|
period_s: float
|
||||||
|
|
||||||
|
def read_once(self, seq: int) -> Optional[TemperatureReading]:
|
||||||
|
"""Poll one measurement and turn it into a reading, or None if no data."""
|
||||||
|
measurements = self.controller.get_measurements()
|
||||||
|
if measurements is None:
|
||||||
|
logger.warning("No measurement returned from laser board (seq=%d)", seq)
|
||||||
|
return None
|
||||||
|
return TemperatureReading(
|
||||||
|
seq=seq,
|
||||||
|
mono_ns=time.monotonic_ns(),
|
||||||
|
temp1=float(measurements.temp1),
|
||||||
|
temp2=float(measurements.temp2),
|
||||||
|
temp_ext1=_opt(getattr(measurements, "temp_ext1", None)),
|
||||||
|
temp_ext2=_opt(getattr(measurements, "temp_ext2", None)),
|
||||||
|
current1=_opt(getattr(measurements, "current1", None)),
|
||||||
|
current2=_opt(getattr(measurements, "current2", None)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, stop_event: Optional[threading.Event] = None) -> None:
|
||||||
|
"""Poll-and-publish until ``stop_event`` is set (runs forever if None).
|
||||||
|
|
||||||
|
Each iteration reads once, publishes, then waits one period. The wait is
|
||||||
|
interruptible via ``stop_event`` for a prompt clean shutdown.
|
||||||
|
"""
|
||||||
|
stop = stop_event or threading.Event()
|
||||||
|
seq = 0
|
||||||
|
logger.info("Temperature monitor started: period=%.3fs", self.period_s)
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
reading = self.read_once(seq)
|
||||||
|
except Exception: # noqa: BLE001 — a transient read error must not kill the monitor
|
||||||
|
logger.warning("Measurement read failed; continuing", exc_info=True)
|
||||||
|
reading = None
|
||||||
|
if reading is not None:
|
||||||
|
self.writer.write(reading)
|
||||||
|
logger.debug(
|
||||||
|
"Published reading seq=%d T1=%.3f T2=%.3f", seq, reading.temp1, reading.temp2
|
||||||
|
)
|
||||||
|
seq += 1
|
||||||
|
stop.wait(self.period_s)
|
||||||
|
logger.info("Temperature monitor stopped after %d readings", seq)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt(value: object) -> Optional[float]:
|
||||||
|
return None if value is None else float(value)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""JSONL append/tail channel carrying per-sweep temperature readings.
|
||||||
|
|
||||||
|
The monitor process appends one JSON object per line; the checker process tails
|
||||||
|
the file from its end and parses each newly-appended line. A newline-delimited
|
||||||
|
file is used (rather than a socket) so the monitor and checker can start, stop,
|
||||||
|
and restart on independent lifecycles without a handshake — the checker simply
|
||||||
|
resumes tailing wherever the file currently ends.
|
||||||
|
|
||||||
|
A reader only ever consumes lines terminated by ``\\n``; a partially-written last
|
||||||
|
line is left buffered until its newline arrives, so a reading is never parsed
|
||||||
|
half-written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Optional, Union
|
||||||
|
|
||||||
|
_PathLike = Union[str, os.PathLike[str]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TemperatureReading:
|
||||||
|
"""One temperature/current snapshot published once per sweep.
|
||||||
|
|
||||||
|
``temp1``/``temp2`` are the internal laser temperatures (the values validated
|
||||||
|
against the setpoints); ``temp_ext1``/``temp_ext2`` are the external
|
||||||
|
thermistors, carried for diagnostics only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
seq: int
|
||||||
|
mono_ns: int
|
||||||
|
temp1: float
|
||||||
|
temp2: float
|
||||||
|
temp_ext1: Optional[float] = None
|
||||||
|
temp_ext2: Optional[float] = None
|
||||||
|
current1: Optional[float] = None
|
||||||
|
current2: Optional[float] = None
|
||||||
|
|
||||||
|
def to_json_line(self) -> str:
|
||||||
|
return json.dumps(asdict(self), separators=(",", ":"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json_line(cls, line: str) -> "TemperatureReading":
|
||||||
|
payload = json.loads(line)
|
||||||
|
return cls(
|
||||||
|
seq=int(payload["seq"]),
|
||||||
|
mono_ns=int(payload["mono_ns"]),
|
||||||
|
temp1=float(payload["temp1"]),
|
||||||
|
temp2=float(payload["temp2"]),
|
||||||
|
temp_ext1=_opt_float(payload.get("temp_ext1")),
|
||||||
|
temp_ext2=_opt_float(payload.get("temp_ext2")),
|
||||||
|
current1=_opt_float(payload.get("current1")),
|
||||||
|
current2=_opt_float(payload.get("current2")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_float(value: object) -> Optional[float]:
|
||||||
|
return None if value is None else float(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingWriter:
|
||||||
|
"""Appends :class:`TemperatureReading` objects to a JSONL file.
|
||||||
|
|
||||||
|
Each write is a single line flushed to the OS so a tailing reader sees it
|
||||||
|
promptly. Use as a context manager or call :meth:`close` explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: _PathLike) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Line-buffered append; each reading is one line.
|
||||||
|
self._fh = self.path.open("a", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
def write(self, reading: TemperatureReading) -> None:
|
||||||
|
self._fh.write(reading.to_json_line() + "\n")
|
||||||
|
self._fh.flush()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if not self._fh.closed:
|
||||||
|
self._fh.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "ReadingWriter":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingReader:
|
||||||
|
"""Tails a JSONL readings file, yielding complete lines as they appear.
|
||||||
|
|
||||||
|
``from_start=False`` (default) begins at the current end of file, so the
|
||||||
|
checker validates readings produced from the moment it starts. Partial
|
||||||
|
trailing lines are buffered until their newline arrives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: _PathLike, *, from_start: bool = False) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._buffer = ""
|
||||||
|
self._pos = 0
|
||||||
|
if not from_start and self.path.exists():
|
||||||
|
self._pos = self.path.stat().st_size
|
||||||
|
|
||||||
|
def poll(self) -> Iterator[TemperatureReading]:
|
||||||
|
"""Yield every complete reading appended since the last poll.
|
||||||
|
|
||||||
|
Malformed lines are skipped silently (a truncated/legacy line must not
|
||||||
|
crash a long-running checker); callers that care can validate seq gaps.
|
||||||
|
"""
|
||||||
|
if not self.path.exists():
|
||||||
|
return
|
||||||
|
with self.path.open("r", encoding="utf-8") as fh:
|
||||||
|
fh.seek(self._pos)
|
||||||
|
chunk = fh.read()
|
||||||
|
self._pos = fh.tell()
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
self._buffer += chunk
|
||||||
|
*complete, self._buffer = self._buffer.split("\n")
|
||||||
|
for line in complete:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
yield TemperatureReading.from_json_line(line)
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
continue
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Variation-session snapshot shared between the temperature monitor and checker.
|
||||||
|
|
||||||
|
When a current-variation task is started, the target static laser temperatures
|
||||||
|
(``static_temp1``/``static_temp2``) and the acceptable tolerance are frozen into a
|
||||||
|
small JSON file. The temperature checker reads this file to know what "correct"
|
||||||
|
means for the run, so it validates against the setpoints that were in effect *at
|
||||||
|
variation start* — independent of any later edits to the run config.
|
||||||
|
|
||||||
|
Only current variation of laser 1 (``CHANGE_CURRENT_LD1``) is supported by the
|
||||||
|
firmware today; the session still records both laser targets because both
|
||||||
|
temperatures are held static during that task and both are validated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
_PathLike = Union[str, os.PathLike[str]]
|
||||||
|
|
||||||
|
# Default IPC locations. Both are overridable via CLI/API so several runs can use
|
||||||
|
# distinct files. Kept in the system temp dir so no project state is polluted.
|
||||||
|
DEFAULT_SESSION_PATH = Path(tempfile.gettempdir()) / "laser_variation_session.json"
|
||||||
|
DEFAULT_READINGS_PATH = Path(tempfile.gettempdir()) / "laser_temp_readings.jsonl"
|
||||||
|
|
||||||
|
DEFAULT_TOLERANCE_C = 0.03
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaserVariationSession:
|
||||||
|
"""Target setpoints and tolerance frozen at variation start."""
|
||||||
|
|
||||||
|
variation_type: str
|
||||||
|
target_temp1: float
|
||||||
|
target_temp2: float
|
||||||
|
tolerance_c: float = DEFAULT_TOLERANCE_C
|
||||||
|
started_at_iso: str = ""
|
||||||
|
|
||||||
|
def save(self, path: _PathLike = DEFAULT_SESSION_PATH) -> Path:
|
||||||
|
"""Atomically write the session snapshot to ``path`` and return it.
|
||||||
|
|
||||||
|
Writes to a temp file in the same directory then renames, so a concurrent
|
||||||
|
checker never observes a half-written file.
|
||||||
|
"""
|
||||||
|
dest = Path(path)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.tmp")
|
||||||
|
tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: _PathLike = DEFAULT_SESSION_PATH) -> "LaserVariationSession":
|
||||||
|
"""Load a session snapshot from ``path``.
|
||||||
|
|
||||||
|
Raises FileNotFoundError if the file is absent and ValueError if it is not
|
||||||
|
a valid session object.
|
||||||
|
"""
|
||||||
|
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"Session file must be a JSON object: {path}")
|
||||||
|
try:
|
||||||
|
return cls(
|
||||||
|
variation_type=str(payload["variation_type"]),
|
||||||
|
target_temp1=float(payload["target_temp1"]),
|
||||||
|
target_temp2=float(payload["target_temp2"]),
|
||||||
|
tolerance_c=float(payload.get("tolerance_c", DEFAULT_TOLERANCE_C)),
|
||||||
|
started_at_iso=str(payload.get("started_at_iso", "")),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"Malformed session file {path}: {exc}") from exc
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from ..exceptions import DeviceDisconnectedError, TimeoutError
|
from ..exceptions import DeviceDisconnectedError, TimeoutError
|
||||||
@@ -44,6 +45,10 @@ class USBTransport:
|
|||||||
self._rx_thread: threading.Thread | None = None
|
self._rx_thread: threading.Thread | None = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._tx_lock = threading.Lock()
|
self._tx_lock = threading.Lock()
|
||||||
|
# Aggregation window for the RX debug trace (see `_rx_loop`).
|
||||||
|
self._rx_debug_bytes = 0
|
||||||
|
self._rx_debug_chunks = 0
|
||||||
|
self._rx_debug_window_start = 0.0
|
||||||
|
|
||||||
self.connected_serial: str | None = None
|
self.connected_serial: str | None = None
|
||||||
|
|
||||||
@@ -270,7 +275,27 @@ class USBTransport:
|
|||||||
|
|
||||||
if data:
|
if data:
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
logger.debug("USB RX %d bytes", len(data))
|
# Aggregate: the free-running datapoint stream completes bulk
|
||||||
|
# reads hundreds of times per second, and a log record per chunk
|
||||||
|
# floods every handler (file, stderr, and the GUI panel, which
|
||||||
|
# marshals each record onto the GUI thread). One summary per
|
||||||
|
# second keeps the throughput trace without the flood.
|
||||||
|
self._rx_debug_bytes += len(data)
|
||||||
|
self._rx_debug_chunks += 1
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._rx_debug_window_start == 0.0:
|
||||||
|
self._rx_debug_window_start = now
|
||||||
|
elif now - self._rx_debug_window_start >= 1.0:
|
||||||
|
logger.debug(
|
||||||
|
"USB RX %d bytes in %d chunks over %.2f s (serial=%s)",
|
||||||
|
self._rx_debug_bytes,
|
||||||
|
self._rx_debug_chunks,
|
||||||
|
now - self._rx_debug_window_start,
|
||||||
|
self.connected_serial,
|
||||||
|
)
|
||||||
|
self._rx_debug_bytes = 0
|
||||||
|
self._rx_debug_chunks = 0
|
||||||
|
self._rx_debug_window_start = now
|
||||||
self._on_data(bytes(data))
|
self._on_data(bytes(data))
|
||||||
logger.debug("USB RX thread stopped")
|
logger.debug("USB RX thread stopped")
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ from python_app.hardware_full.librevna_multi_device_driver.protocol import Packe
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The sweep free-runs by design, so devices stream datapoints continuously even
|
||||||
|
# while no acquisition is consuming them (e.g. an operator pausing between manual
|
||||||
|
# combo captures). An unbounded queue then grows without limit — hundreds of MB
|
||||||
|
# over a few minutes — and the next acquisition's drain spends seconds discarding
|
||||||
|
# the backlog on the GUI thread. Bound the queue and drop the OLDEST packet on
|
||||||
|
# overflow: every acquisition drains stale packets before collecting anyway, and
|
||||||
|
# whenever packets actually matter (ACK waits, cycle collection) a consumer is
|
||||||
|
# already pulling, so the queue never approaches the bound. Sized to hold many
|
||||||
|
# full sweeps of datapoints with a wide margin.
|
||||||
|
_RECEIVED_PACKET_QUEUE_MAX = 32768
|
||||||
|
|
||||||
|
|
||||||
class LibreVnaUsbBulkConnection:
|
class LibreVnaUsbBulkConnection:
|
||||||
"""Minimal packet transport for one LibreVNA device."""
|
"""Minimal packet transport for one LibreVNA device."""
|
||||||
@@ -29,7 +40,9 @@ class LibreVnaUsbBulkConnection:
|
|||||||
raise ValueError("serial_number is required for multi-device acquisition")
|
raise ValueError("serial_number is required for multi-device acquisition")
|
||||||
self.serial_number = serial_number
|
self.serial_number = serial_number
|
||||||
self._scanner = FrameScanner()
|
self._scanner = FrameScanner()
|
||||||
self._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue()
|
self._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue(
|
||||||
|
maxsize=_RECEIVED_PACKET_QUEUE_MAX
|
||||||
|
)
|
||||||
self._fatal_error: Exception | None = None
|
self._fatal_error: Exception | None = None
|
||||||
self._fatal_lock = threading.Lock()
|
self._fatal_lock = threading.Lock()
|
||||||
self._transport = USBTransport(
|
self._transport = USBTransport(
|
||||||
@@ -108,7 +121,20 @@ class LibreVnaUsbBulkConnection:
|
|||||||
logger.warning("Dropping unparseable USB chunk from %s: %s", self.serial_number, exc)
|
logger.warning("Dropping unparseable USB chunk from %s: %s", self.serial_number, exc)
|
||||||
return
|
return
|
||||||
for packet in packets:
|
for packet in packets:
|
||||||
self._received_packets.put((int(packet.type), bytes(packet.payload)))
|
entry = (int(packet.type), bytes(packet.payload))
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self._received_packets.put_nowait(entry)
|
||||||
|
break
|
||||||
|
except queue.Full:
|
||||||
|
# Blocking here would stall the USB read thread; discard the
|
||||||
|
# oldest packet instead — stale data is what the pre-collect
|
||||||
|
# drain throws away anyway. Racing a concurrent consumer just
|
||||||
|
# means the queue already has room again.
|
||||||
|
try:
|
||||||
|
self._received_packets.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
def _on_disconnect(self, exc: Exception) -> None:
|
def _on_disconnect(self, exc: Exception) -> None:
|
||||||
"""Record an asynchronous transport disconnect as the fatal error."""
|
"""Record an asynchronous transport disconnect as the fatal error."""
|
||||||
|
|||||||
@@ -332,10 +332,15 @@ class MultiDeviceLibreVnaService:
|
|||||||
assert self._sweep_configuration is not None
|
assert self._sweep_configuration is not None
|
||||||
|
|
||||||
self._controller.configure_continuous_sweep(self._sweep_configuration)
|
self._controller.configure_continuous_sweep(self._sweep_configuration)
|
||||||
|
# Bound the sweep itself rather than reusing `capture_start_ns`: the latter
|
||||||
|
# is taken before any retry/recovery, so it would overstate how long the
|
||||||
|
# traces below took to measure.
|
||||||
|
sweep_start_ns = time.monotonic_ns()
|
||||||
result = self._controller.collect_running_sweep_cycles(
|
result = self._controller.collect_running_sweep_cycles(
|
||||||
1,
|
1,
|
||||||
datapoint_timeout_seconds=LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS,
|
datapoint_timeout_seconds=LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
sweep_end_ns = time.monotonic_ns()
|
||||||
normalized_s_parameters = {
|
normalized_s_parameters = {
|
||||||
str(name).lower(): np.asarray(values, dtype=np.complex64)
|
str(name).lower(): np.asarray(values, dtype=np.complex64)
|
||||||
for name, values in result.s_parameters.items()
|
for name, values in result.s_parameters.items()
|
||||||
@@ -356,6 +361,11 @@ class MultiDeviceLibreVnaService:
|
|||||||
frequency_hz=frequencies,
|
frequency_hz=frequencies,
|
||||||
s11=reflection,
|
s11=reflection,
|
||||||
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
|
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
|
||||||
|
# Every combo comes out of the same synchronized cycle, so
|
||||||
|
# they all share one window — no combo was measured earlier
|
||||||
|
# or later than another here.
|
||||||
|
capture_start_ns=sweep_start_ns,
|
||||||
|
capture_end_ns=sweep_end_ns,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -368,6 +378,7 @@ class MultiDeviceLibreVnaService:
|
|||||||
|
|
||||||
def _acquire_mock_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
|
def _acquire_mock_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
|
||||||
assert self._sweep_configuration is not None
|
assert self._sweep_configuration is not None
|
||||||
|
mock_sweep_start_ns = time.monotonic_ns()
|
||||||
points = int(self._sweep_configuration.points)
|
points = int(self._sweep_configuration.points)
|
||||||
frequencies = np.linspace(
|
frequencies = np.linspace(
|
||||||
self._sweep_configuration.start_hz,
|
self._sweep_configuration.start_hz,
|
||||||
@@ -393,6 +404,8 @@ class MultiDeviceLibreVnaService:
|
|||||||
frequency_hz=frequencies,
|
frequency_hz=frequencies,
|
||||||
s11=s11,
|
s11=s11,
|
||||||
s21=s21,
|
s21=s21,
|
||||||
|
capture_start_ns=mock_sweep_start_ns,
|
||||||
|
capture_end_ns=time.monotonic_ns(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._mock_phase += 0.05
|
self._mock_phase += 0.05
|
||||||
|
|||||||
@@ -183,7 +183,11 @@ class Sn9000Service:
|
|||||||
capture_start_ns = time.monotonic_ns()
|
capture_start_ns = time.monotonic_ns()
|
||||||
|
|
||||||
s_parameters = self._query_sweep_s_parameters(points)
|
s_parameters = self._query_sweep_s_parameters(points)
|
||||||
traces = self._assemble_traces(s_parameters)
|
traces = self._assemble_traces(
|
||||||
|
s_parameters,
|
||||||
|
sweep_start_ns=capture_start_ns,
|
||||||
|
sweep_end_ns=time.monotonic_ns(),
|
||||||
|
)
|
||||||
|
|
||||||
return SweepCollection(
|
return SweepCollection(
|
||||||
collection_id=int(collection_id),
|
collection_id=int(collection_id),
|
||||||
@@ -256,7 +260,13 @@ class Sn9000Service:
|
|||||||
def _uses_pyvisa_py_backend(self) -> bool:
|
def _uses_pyvisa_py_backend(self) -> bool:
|
||||||
return self.visa_library == "@py" or self.visa_library.endswith("@py")
|
return self.visa_library == "@py" or self.visa_library.endswith("@py")
|
||||||
|
|
||||||
def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]:
|
def _assemble_traces(
|
||||||
|
self,
|
||||||
|
s_parameters: dict[str, np.ndarray],
|
||||||
|
*,
|
||||||
|
sweep_start_ns: int,
|
||||||
|
sweep_end_ns: int,
|
||||||
|
) -> list[TraceData]:
|
||||||
frequency_hz = self._require_frequency_axis()
|
frequency_hz = self._require_frequency_axis()
|
||||||
traces: list[TraceData] = []
|
traces: list[TraceData] = []
|
||||||
for output_position, output_port in enumerate(_OUTPUT_PORT_BY_INDEX):
|
for output_position, output_port in enumerate(_OUTPUT_PORT_BY_INDEX):
|
||||||
@@ -269,6 +279,10 @@ class Sn9000Service:
|
|||||||
frequency_hz=frequency_hz,
|
frequency_hz=frequency_hz,
|
||||||
s11=reflection,
|
s11=reflection,
|
||||||
s21=transmission,
|
s21=transmission,
|
||||||
|
# One triggered sweep produces every port pair at once, so
|
||||||
|
# all combos share the sweep's window.
|
||||||
|
capture_start_ns=int(sweep_start_ns),
|
||||||
|
capture_end_ns=int(sweep_end_ns),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return traces
|
return traces
|
||||||
|
|||||||
@@ -83,42 +83,8 @@ class SwitchedMatrixRadarService:
|
|||||||
|
|
||||||
for out_k in range(out_steps):
|
for out_k in range(out_steps):
|
||||||
for in_k in range(in_steps):
|
for in_k in range(in_steps):
|
||||||
step_start_ns = time.monotonic_ns()
|
for trace in self._acquire_step_traces(out_k, in_k, collection_id):
|
||||||
if self.output_switch is not None:
|
slots[trace.combo.output * total_inputs + trace.combo.input] = trace
|
||||||
self.output_switch.switch_to(out_k)
|
|
||||||
if self.input_switch is not None:
|
|
||||||
self.input_switch.switch_to(in_k)
|
|
||||||
switched_ns = time.monotonic_ns()
|
|
||||||
# Settle AFTER the last switch change and BEFORE collecting, so the
|
|
||||||
# cycle we anchor on starts with the RF path already stable.
|
|
||||||
if self.settling_ms > 0:
|
|
||||||
time.sleep(self.settling_ms / 1000.0)
|
|
||||||
settled_ns = time.monotonic_ns()
|
|
||||||
|
|
||||||
sub = self.inner.acquire_collection(collection_id)
|
|
||||||
inner_end_ns = time.monotonic_ns()
|
|
||||||
logger.debug(
|
|
||||||
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
|
|
||||||
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
|
|
||||||
collection_id,
|
|
||||||
out_k,
|
|
||||||
in_k,
|
|
||||||
(
|
|
||||||
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
|
|
||||||
if self._last_inner_end_ns
|
|
||||||
else "n/a"
|
|
||||||
),
|
|
||||||
(switched_ns - step_start_ns) / 1e6,
|
|
||||||
(settled_ns - switched_ns) / 1e6,
|
|
||||||
(inner_end_ns - settled_ns) / 1e6,
|
|
||||||
)
|
|
||||||
self._last_inner_end_ns = inner_end_ns
|
|
||||||
for trace in sub.traces:
|
|
||||||
input_pos = in_k * self.inner_input_positions + int(trace.combo.input)
|
|
||||||
output_pos = out_k * self.inner_output_positions + int(trace.combo.output)
|
|
||||||
slots[output_pos * total_inputs + input_pos] = replace(
|
|
||||||
trace, combo=ComboKey(input=input_pos, output=output_pos)
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(trace is None for trace in slots):
|
if any(trace is None for trace in slots):
|
||||||
missing = sum(1 for trace in slots if trace is None)
|
missing = sum(1 for trace in slots if trace is None)
|
||||||
@@ -134,6 +100,100 @@ class SwitchedMatrixRadarService:
|
|||||||
capture_end_ns=time.monotonic_ns(),
|
capture_end_ns=time.monotonic_ns(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def acquire_combo_collection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
input_pos: int,
|
||||||
|
output_pos: int,
|
||||||
|
collection_id: int = 1,
|
||||||
|
) -> SweepCollection:
|
||||||
|
"""Acquire only the physical switch step that carries one widened combo.
|
||||||
|
|
||||||
|
The per-combo capture workflows need a single trace at a time; sweeping
|
||||||
|
every switch position for that (a full ``acquire_collection``) multiplies
|
||||||
|
the capture time by the number of physical steps and freezes the caller
|
||||||
|
for the whole sweep. One widened combo lives entirely inside one
|
||||||
|
(out_k, in_k) step, so acquiring just that step is sufficient. The result
|
||||||
|
contains that step's traces with widened combo keys, including the
|
||||||
|
requested combo.
|
||||||
|
"""
|
||||||
|
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
|
||||||
|
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
|
||||||
|
total_inputs = in_steps * self.inner_input_positions
|
||||||
|
total_outputs = out_steps * self.inner_output_positions
|
||||||
|
if not (0 <= int(input_pos) < total_inputs and 0 <= int(output_pos) < total_outputs):
|
||||||
|
raise ValueError(
|
||||||
|
f"Widened combo out of range: input={input_pos} (of {total_inputs}), "
|
||||||
|
f"output={output_pos} (of {total_outputs})"
|
||||||
|
)
|
||||||
|
|
||||||
|
capture_start_ns = time.monotonic_ns()
|
||||||
|
out_k = int(output_pos) // self.inner_output_positions
|
||||||
|
in_k = int(input_pos) // self.inner_input_positions
|
||||||
|
traces = self._acquire_step_traces(out_k, in_k, collection_id)
|
||||||
|
return SweepCollection(
|
||||||
|
collection_id=int(collection_id),
|
||||||
|
monotonic_ns=time.monotonic_ns(),
|
||||||
|
traces=traces,
|
||||||
|
capture_start_ns=capture_start_ns,
|
||||||
|
capture_end_ns=time.monotonic_ns(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _acquire_step_traces(self, out_k: int, in_k: int, collection_id: int) -> list[TraceData]:
|
||||||
|
"""Drive both switches to one step, settle, and collect its widened traces.
|
||||||
|
|
||||||
|
Every returned trace carries the monotonic window of the inner collection
|
||||||
|
that produced it, so a consumer can tell when each combo of a switched
|
||||||
|
matrix was really measured instead of only when the whole cycle began and
|
||||||
|
ended. The switch drive and settling are deliberately outside the window.
|
||||||
|
"""
|
||||||
|
step_start_ns = time.monotonic_ns()
|
||||||
|
if self.output_switch is not None:
|
||||||
|
self.output_switch.switch_to(out_k)
|
||||||
|
if self.input_switch is not None:
|
||||||
|
self.input_switch.switch_to(in_k)
|
||||||
|
switched_ns = time.monotonic_ns()
|
||||||
|
# Settle AFTER the last switch change and BEFORE collecting, so the
|
||||||
|
# cycle we anchor on starts with the RF path already stable.
|
||||||
|
if self.settling_ms > 0:
|
||||||
|
time.sleep(self.settling_ms / 1000.0)
|
||||||
|
settled_ns = time.monotonic_ns()
|
||||||
|
|
||||||
|
sub = self.inner.acquire_collection(collection_id)
|
||||||
|
inner_end_ns = time.monotonic_ns()
|
||||||
|
logger.debug(
|
||||||
|
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
|
||||||
|
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
|
||||||
|
collection_id,
|
||||||
|
out_k,
|
||||||
|
in_k,
|
||||||
|
(
|
||||||
|
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
|
||||||
|
if self._last_inner_end_ns
|
||||||
|
else "n/a"
|
||||||
|
),
|
||||||
|
(switched_ns - step_start_ns) / 1e6,
|
||||||
|
(settled_ns - switched_ns) / 1e6,
|
||||||
|
(inner_end_ns - settled_ns) / 1e6,
|
||||||
|
)
|
||||||
|
self._last_inner_end_ns = inner_end_ns
|
||||||
|
return [
|
||||||
|
replace(
|
||||||
|
trace,
|
||||||
|
combo=ComboKey(
|
||||||
|
input=in_k * self.inner_input_positions + int(trace.combo.input),
|
||||||
|
output=out_k * self.inner_output_positions + int(trace.combo.output),
|
||||||
|
),
|
||||||
|
# Keep the inner service's own per-trace window when it reports one
|
||||||
|
# (it knows its internal port order better than this step does);
|
||||||
|
# otherwise fall back to the window of this inner collection.
|
||||||
|
capture_start_ns=int(trace.capture_start_ns) or settled_ns,
|
||||||
|
capture_end_ns=int(trace.capture_end_ns) or inner_end_ns,
|
||||||
|
)
|
||||||
|
for trace in sub.traces
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_physical_switch(
|
def build_physical_switch(
|
||||||
model: SwitchModel,
|
model: SwitchModel,
|
||||||
physical_positions: int,
|
physical_positions: int,
|
||||||
|
|||||||
@@ -32,12 +32,22 @@ class ComboKey:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TraceData:
|
class TraceData:
|
||||||
"""One frequency-domain trace set for a specific switch combination."""
|
"""One frequency-domain trace set for a specific switch combination.
|
||||||
|
|
||||||
|
``capture_start_ns``/``capture_end_ns`` bound the monotonic window in which
|
||||||
|
THIS trace's sweep was measured, excluding the switch drive and settling that
|
||||||
|
preceded it. In switched modes a collection is assembled combo by combo over
|
||||||
|
many milliseconds, so the collection-level window says nothing about when any
|
||||||
|
individual combo was measured — these do. Zero on both means the producer did
|
||||||
|
not report per-trace timing.
|
||||||
|
"""
|
||||||
|
|
||||||
combo: ComboKey
|
combo: ComboKey
|
||||||
frequency_hz: np.ndarray
|
frequency_hz: np.ndarray
|
||||||
s11: np.ndarray
|
s11: np.ndarray
|
||||||
s21: np.ndarray
|
s21: np.ndarray
|
||||||
|
capture_start_ns: int = 0
|
||||||
|
capture_end_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -282,10 +282,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.range_comp_power,
|
gui.processing.gpr.range_comp_power,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
angle_comp_power=_optional_float(
|
object_min_frac=_optional_float(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"angle_comp_power",
|
"object_min_frac",
|
||||||
gui.processing.gpr.angle_comp_power,
|
gui.processing.gpr.object_min_frac,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
score_mode=_optional_string(
|
score_mode=_optional_string(
|
||||||
@@ -336,6 +336,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.draw_top_m_objects,
|
gui.processing.gpr.draw_top_m_objects,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
|
object_approach_min_frames=_optional_int(
|
||||||
|
gpr_object,
|
||||||
|
"object_approach_min_frames",
|
||||||
|
gui.processing.gpr.object_approach_min_frames,
|
||||||
|
"gui.processing.gpr",
|
||||||
|
),
|
||||||
start_freq_mhz=_optional_float(
|
start_freq_mhz=_optional_float(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"start_freq_mhz",
|
"start_freq_mhz",
|
||||||
@@ -378,12 +384,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.render_mode,
|
gui.processing.gpr.render_mode,
|
||||||
"gui.processing.gpr",
|
"gui.processing.gpr",
|
||||||
),
|
),
|
||||||
min_visible_score=_optional_float(
|
|
||||||
gpr_object,
|
|
||||||
"min_visible_score",
|
|
||||||
gui.processing.gpr.min_visible_score,
|
|
||||||
"gui.processing.gpr",
|
|
||||||
),
|
|
||||||
visible_x_min_m=_optional_float(
|
visible_x_min_m=_optional_float(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"visible_x_min_m",
|
"visible_x_min_m",
|
||||||
@@ -473,14 +473,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
)
|
)
|
||||||
if gui.processing.gpr.range_comp_power < 0.0:
|
if gui.processing.gpr.range_comp_power < 0.0:
|
||||||
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
|
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
|
||||||
if gui.processing.gpr.angle_comp_power < 0.0:
|
if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0:
|
||||||
raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0")
|
raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]")
|
||||||
if gui.processing.gpr.min_visible_score < 0.0:
|
|
||||||
raise ValueError("gui.processing.gpr.min_visible_score must be >= 0")
|
|
||||||
if gui.processing.gpr.max_detected_objects_to_draw < 0:
|
if gui.processing.gpr.max_detected_objects_to_draw < 0:
|
||||||
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
|
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
|
||||||
if gui.processing.gpr.draw_top_m_objects < 0:
|
if gui.processing.gpr.draw_top_m_objects < 0:
|
||||||
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
|
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
|
||||||
|
if gui.processing.gpr.object_approach_min_frames < 1:
|
||||||
|
raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1")
|
||||||
if gui.processing.legacy_gpr.comp_power < 0.0:
|
if gui.processing.legacy_gpr.comp_power < 0.0:
|
||||||
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
|
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
|
||||||
if gui.processing.legacy_gpr.snr_thresh < 0.0:
|
if gui.processing.legacy_gpr.snr_thresh < 0.0:
|
||||||
@@ -593,7 +593,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"min_depth_m": gui.processing.gpr.min_depth_m,
|
"min_depth_m": gui.processing.gpr.min_depth_m,
|
||||||
"max_depth_m": gui.processing.gpr.max_depth_m,
|
"max_depth_m": gui.processing.gpr.max_depth_m,
|
||||||
"range_comp_power": gui.processing.gpr.range_comp_power,
|
"range_comp_power": gui.processing.gpr.range_comp_power,
|
||||||
"angle_comp_power": gui.processing.gpr.angle_comp_power,
|
"object_min_frac": gui.processing.gpr.object_min_frac,
|
||||||
"score_mode": gui.processing.gpr.score_mode,
|
"score_mode": gui.processing.gpr.score_mode,
|
||||||
"motion_mode": gui.processing.gpr.motion_mode,
|
"motion_mode": gui.processing.gpr.motion_mode,
|
||||||
"look_angle_deg": gui.processing.gpr.look_angle_deg,
|
"look_angle_deg": gui.processing.gpr.look_angle_deg,
|
||||||
@@ -602,6 +602,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
|
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
|
||||||
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
|
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
|
||||||
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
|
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
|
||||||
|
"object_approach_min_frames": gui.processing.gpr.object_approach_min_frames,
|
||||||
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
||||||
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
|
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
|
||||||
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
||||||
@@ -609,7 +610,6 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
|
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
|
||||||
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
|
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
|
||||||
"render_mode": gui.processing.gpr.render_mode,
|
"render_mode": gui.processing.gpr.render_mode,
|
||||||
"min_visible_score": gui.processing.gpr.min_visible_score,
|
|
||||||
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
|
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
|
||||||
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
|
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
|
||||||
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
|
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ class GuiGprStateModel:
|
|||||||
min_depth_m: float = 2.0
|
min_depth_m: float = 2.0
|
||||||
max_depth_m: float = 14.0
|
max_depth_m: float = 14.0
|
||||||
range_comp_power: float = 0.1
|
range_comp_power: float = 0.1
|
||||||
angle_comp_power: float = 0.0
|
# BP object-detection stop level, fraction of the global peak (Horns_motion_3libre.py
|
||||||
|
# BP_OBJECT_MIN_FRAC). Angle compensation and permittivity are fixed for coherent BP
|
||||||
|
# (Python 0.3 block), so they are not exposed here.
|
||||||
|
object_min_frac: float = 0.7
|
||||||
score_mode: str = "combined"
|
score_mode: str = "combined"
|
||||||
motion_mode: str = "int_minus"
|
motion_mode: str = "int_minus"
|
||||||
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
|
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
|
||||||
@@ -75,6 +78,9 @@ class GuiGprStateModel:
|
|||||||
ignore_socket_speed_enabled: bool = False
|
ignore_socket_speed_enabled: bool = False
|
||||||
max_detected_objects_to_draw: int = 5
|
max_detected_objects_to_draw: int = 5
|
||||||
draw_top_m_objects: int = 2
|
draw_top_m_objects: int = 2
|
||||||
|
# Cross-frame approach filter: show an object only after it persists as a
|
||||||
|
# motion-consistent track this many consecutive frames (<= 1 disables it).
|
||||||
|
object_approach_min_frames: int = 3
|
||||||
start_freq_mhz: float = 3000.0
|
start_freq_mhz: float = 3000.0
|
||||||
stop_freq_mhz: float = 6000.0
|
stop_freq_mhz: float = 6000.0
|
||||||
background_subtract_enabled: bool = True
|
background_subtract_enabled: bool = True
|
||||||
@@ -82,7 +88,6 @@ class GuiGprStateModel:
|
|||||||
remove_sidelobe_objects_enabled: bool = True
|
remove_sidelobe_objects_enabled: bool = True
|
||||||
imaging_plane_y_m: float = 0.0
|
imaging_plane_y_m: float = 0.0
|
||||||
render_mode: str = "heatmap"
|
render_mode: str = "heatmap"
|
||||||
min_visible_score: float = 0.0
|
|
||||||
visible_x_min_m: float = -2.0
|
visible_x_min_m: float = -2.0
|
||||||
visible_x_max_m: float = 2.0
|
visible_x_max_m: float = 2.0
|
||||||
visible_z_min_m: float = 0.0
|
visible_z_min_m: float = 0.0
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
model.radar.laser_control.variation.delay_time = _read_int(
|
model.radar.laser_control.variation.delay_time = _read_int(
|
||||||
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
|
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
|
||||||
)
|
)
|
||||||
|
model.radar.laser_control.variation.temp_tolerance_c = _read_float(
|
||||||
|
laser_variation_payload,
|
||||||
|
"temp_tolerance_c",
|
||||||
|
model.radar.laser_control.variation.temp_tolerance_c,
|
||||||
|
)
|
||||||
|
|
||||||
load_switch_payload(port1_payload, model.output_switch)
|
load_switch_payload(port1_payload, model.output_switch)
|
||||||
load_switch_payload(port2_payload, model.input_switch)
|
load_switch_payload(port2_payload, model.input_switch)
|
||||||
@@ -552,6 +557,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
|||||||
"step": model.radar.laser_control.variation.step,
|
"step": model.radar.laser_control.variation.step,
|
||||||
"time_step": model.radar.laser_control.variation.time_step,
|
"time_step": model.radar.laser_control.variation.time_step,
|
||||||
"delay_time": model.radar.laser_control.variation.delay_time,
|
"delay_time": model.radar.laser_control.variation.delay_time,
|
||||||
|
"temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"sweep": sweep_payload,
|
"sweep": sweep_payload,
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ class LaserVariationModeModel:
|
|||||||
step: float = 0.1
|
step: float = 0.1
|
||||||
time_step: int = 20
|
time_step: int = 20
|
||||||
delay_time: int = 3
|
delay_time: int = 3
|
||||||
|
# Max allowed |measured - target| laser temperature before the temperature
|
||||||
|
# checker warns, °C. Applied independently to both lasers (temp1/temp2).
|
||||||
|
temp_tolerance_c: float = 0.03
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class ProcessingLiveConfig:
|
|||||||
gpr_min_depth_m: float = 2.0
|
gpr_min_depth_m: float = 2.0
|
||||||
gpr_max_depth_m: float = 14.0
|
gpr_max_depth_m: float = 14.0
|
||||||
gpr_range_comp_power: float = 0.1
|
gpr_range_comp_power: float = 0.1
|
||||||
gpr_angle_comp_power: float = 0.0
|
|
||||||
gpr_comp_power: float = 0.2
|
gpr_comp_power: float = 0.2
|
||||||
|
gpr_object_min_frac: float = 0.7
|
||||||
gpr_score_mode: str = "combined"
|
gpr_score_mode: str = "combined"
|
||||||
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
|
||||||
# correction) or "int_focus" (focusing residual only). Mirrors Python
|
# correction) or "int_focus" (focusing residual only). Mirrors Python
|
||||||
@@ -41,6 +41,7 @@ class ProcessingLiveConfig:
|
|||||||
gpr_motion_mode: str = "int_minus"
|
gpr_motion_mode: str = "int_minus"
|
||||||
gpr_max_detected_objects_to_draw: int = 5
|
gpr_max_detected_objects_to_draw: int = 5
|
||||||
gpr_draw_top_m_objects: int = 2
|
gpr_draw_top_m_objects: int = 2
|
||||||
|
gpr_object_approach_min_frames: int = 3
|
||||||
gpr_speed_m_s: float = 0.0
|
gpr_speed_m_s: float = 0.0
|
||||||
gpr_look_angle_deg: float = 0.0
|
gpr_look_angle_deg: float = 0.0
|
||||||
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
|
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
|
||||||
@@ -61,8 +62,9 @@ class ProcessingLiveConfig:
|
|||||||
gpr_background_mean_count: int = 10
|
gpr_background_mean_count: int = 10
|
||||||
gpr_remove_sidelobe_objects_enabled: bool = True
|
gpr_remove_sidelobe_objects_enabled: bool = True
|
||||||
gpr_imaging_plane_y_m: float = 0.0
|
gpr_imaging_plane_y_m: float = 0.0
|
||||||
# Locator filter parameters consumed by the C++ TCP locator server.
|
# Locator filter parameter consumed by the C++ TCP locator server. Coherent BP
|
||||||
gpr_min_visible_score: float = 0.0
|
# objects are already finalized in the processor (no score threshold); only legacy
|
||||||
|
# GPR still thresholds, on a pair count.
|
||||||
legacy_gpr_min_visible_pair_count: float = 0.0
|
legacy_gpr_min_visible_pair_count: float = 0.0
|
||||||
# Visible X/Z window (metres). The locator and the desktop plot both clip
|
# Visible X/Z window (metres). The locator and the desktop plot both clip
|
||||||
# detected objects to this window, so the socket broadcasts only what is shown.
|
# detected objects to this window, so the socket broadcasts only what is shown.
|
||||||
@@ -109,12 +111,13 @@ class ProcessingLiveConfig:
|
|||||||
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
||||||
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
||||||
"gpr_range_comp_power": float(self.gpr_range_comp_power),
|
"gpr_range_comp_power": float(self.gpr_range_comp_power),
|
||||||
"gpr_angle_comp_power": float(self.gpr_angle_comp_power),
|
|
||||||
"gpr_comp_power": float(self.gpr_comp_power),
|
"gpr_comp_power": float(self.gpr_comp_power),
|
||||||
|
"gpr_object_min_frac": float(self.gpr_object_min_frac),
|
||||||
"gpr_score_mode": str(self.gpr_score_mode),
|
"gpr_score_mode": str(self.gpr_score_mode),
|
||||||
"gpr_motion_mode": str(self.gpr_motion_mode),
|
"gpr_motion_mode": str(self.gpr_motion_mode),
|
||||||
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
|
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
|
||||||
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
|
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
|
||||||
|
"gpr_object_approach_min_frames": int(self.gpr_object_approach_min_frames),
|
||||||
"gpr_speed_m_s": float(self.gpr_speed_m_s),
|
"gpr_speed_m_s": float(self.gpr_speed_m_s),
|
||||||
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
|
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
|
||||||
"gpr_direction_sign": float(self.gpr_direction_sign),
|
"gpr_direction_sign": float(self.gpr_direction_sign),
|
||||||
@@ -128,7 +131,6 @@ class ProcessingLiveConfig:
|
|||||||
"gpr_background_mean_count": int(self.gpr_background_mean_count),
|
"gpr_background_mean_count": int(self.gpr_background_mean_count),
|
||||||
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
|
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
|
||||||
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
|
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
|
||||||
"gpr_min_visible_score": float(self.gpr_min_visible_score),
|
|
||||||
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
||||||
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
||||||
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
||||||
|
|||||||
@@ -54,12 +54,27 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optional trailer, written after the trace blocks by newer producers: the
|
||||||
|
# collection capture window, then a per-trace window table. Both stages are
|
||||||
|
# optional so payloads from an older producer still decode (the timestamps
|
||||||
|
# simply stay zero).
|
||||||
capture_start_ns = 0
|
capture_start_ns = 0
|
||||||
capture_end_ns = 0
|
capture_end_ns = 0
|
||||||
if cursor.remaining_bytes() == 16:
|
if cursor.remaining_bytes() != 0:
|
||||||
|
if cursor.remaining_bytes() < 16:
|
||||||
|
raise ValueError("Truncated capture window in trace collection")
|
||||||
capture_start_ns = cursor.read_u64()
|
capture_start_ns = cursor.read_u64()
|
||||||
capture_end_ns = cursor.read_u64()
|
capture_end_ns = cursor.read_u64()
|
||||||
elif cursor.remaining_bytes() != 0:
|
|
||||||
|
if cursor.remaining_bytes() != 0:
|
||||||
|
trace_time_count = cursor.read_u32()
|
||||||
|
if trace_time_count != len(traces):
|
||||||
|
raise ValueError("Per-trace capture window count does not match trace count")
|
||||||
|
for trace in traces:
|
||||||
|
trace.capture_start_ns = cursor.read_u64()
|
||||||
|
trace.capture_end_ns = cursor.read_u64()
|
||||||
|
|
||||||
|
if cursor.remaining_bytes() != 0:
|
||||||
raise ValueError("Unexpected trailing bytes in trace collection")
|
raise ValueError("Unexpected trailing bytes in trace collection")
|
||||||
|
|
||||||
return SweepCollection(
|
return SweepCollection(
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class TraceRecord:
|
|||||||
stage_index: int
|
stage_index: int
|
||||||
frequency_hz: np.ndarray
|
frequency_hz: np.ndarray
|
||||||
samples: np.ndarray
|
samples: np.ndarray
|
||||||
|
# End of this trace's own sweep, from the snapshot's per-trace metadata; 0 for a
|
||||||
|
# snapshot recorded before per-trace timing existed.
|
||||||
|
capture_end_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -128,6 +131,7 @@ def _load_stage_records(
|
|||||||
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
|
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
|
||||||
frequency_hz=frequency_hz,
|
frequency_hz=frequency_hz,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
|
capture_end_ns=int(trace_meta.get("capture_end_ns", 0)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,7 +218,15 @@ def _build_sweep_history(
|
|||||||
|
|
||||||
start_freq_hz = float(base.frequency_hz[0])
|
start_freq_hz = float(base.frequency_hz[0])
|
||||||
stop_freq_hz = float(base.frequency_hz[-1])
|
stop_freq_hz = float(base.frequency_hz[-1])
|
||||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
# Prefer this trace's own sweep time: with a switching matrix the combos of
|
||||||
|
# one collection are measured milliseconds apart, so the collection
|
||||||
|
# timestamp misplaces every combo but the last.
|
||||||
|
if base.capture_end_ns > 0:
|
||||||
|
timestamp_sec = float(base.capture_end_ns) / 1_000_000_000.0
|
||||||
|
elif base.monotonic_ns > 0:
|
||||||
|
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0
|
||||||
|
else:
|
||||||
|
timestamp_sec = float(fallback_index)
|
||||||
|
|
||||||
history.append(
|
history.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ _OPEN_RETRY_LOG_EVERY = 30
|
|||||||
def _open_radar_with_retry(
|
def _open_radar_with_retry(
|
||||||
config: RunConfigModel,
|
config: RunConfigModel,
|
||||||
radar: KamilAdcService,
|
radar: KamilAdcService,
|
||||||
input_switch: SwitchService,
|
input_switch: SwitchService | None,
|
||||||
output_switch: SwitchService,
|
output_switch: SwitchService | None,
|
||||||
stop_requested: threading.Event,
|
stop_requested: threading.Event,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Open+configure the radar and both switches, retrying forever until stop.
|
"""Open+configure the radar and both switches, retrying forever until stop.
|
||||||
@@ -48,14 +48,19 @@ def _open_radar_with_retry(
|
|||||||
relaunched collector starts clean. Returns ``True`` once everything is open, or
|
relaunched collector starts clean. Returns ``True`` once everything is open, or
|
||||||
``False`` if a stop was requested before the device became available. Backoff is
|
``False`` if a stop was requested before the device became available. Backoff is
|
||||||
capped and every wait is interruptible by SIGTERM.
|
capped and every wait is interruptible by SIGTERM.
|
||||||
|
|
||||||
|
``input_switch``/``output_switch`` are ``None`` in switch-aware mode, where the
|
||||||
|
collector owns the GPIO lines and the producer must not open them.
|
||||||
"""
|
"""
|
||||||
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
|
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
|
||||||
# while still "open", so a mid-run reconnect must close them to force a fresh
|
# while still "open", so a mid-run reconnect must close them to force a fresh
|
||||||
# collector relaunch and TTY re-attach.
|
# collector relaunch and TTY re-attach.
|
||||||
with suppress(Exception):
|
if input_switch is not None:
|
||||||
input_switch.close()
|
with suppress(Exception):
|
||||||
with suppress(Exception):
|
input_switch.close()
|
||||||
output_switch.close()
|
if output_switch is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
output_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
radar.close()
|
radar.close()
|
||||||
|
|
||||||
@@ -65,15 +70,19 @@ def _open_radar_with_retry(
|
|||||||
try:
|
try:
|
||||||
radar.open(stop_event=stop_requested)
|
radar.open(stop_event=stop_requested)
|
||||||
radar.configure(config.radar.sweep)
|
radar.configure(config.radar.sweep)
|
||||||
output_switch.open()
|
if output_switch is not None:
|
||||||
input_switch.open()
|
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
|
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
|
||||||
# Drop any partial open (collector process, TTY reader, switches)
|
# Drop any partial open (collector process, TTY reader, switches)
|
||||||
# before the next attempt so the relaunch starts from a clean state.
|
# before the next attempt so the relaunch starts from a clean state.
|
||||||
with suppress(Exception):
|
if input_switch is not None:
|
||||||
input_switch.close()
|
with suppress(Exception):
|
||||||
with suppress(Exception):
|
input_switch.close()
|
||||||
output_switch.close()
|
if output_switch is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
output_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
radar.close()
|
radar.close()
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -136,9 +145,26 @@ def main() -> int:
|
|||||||
"Opened SHM ring writers: raw=%s, raw_tap=%s",
|
"Opened SHM ring writers: raw=%s, raw_tap=%s",
|
||||||
config.rings.raw.name, config.rings.raw_tap.name,
|
config.rings.raw.name, config.rings.raw_tap.name,
|
||||||
)
|
)
|
||||||
radar = KamilAdcService(config)
|
# Switch-aware mode: with native switches the collector drives the RF switches
|
||||||
input_switch = SwitchService.from_model(config.input_switch)
|
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
|
||||||
output_switch = SwitchService.from_model(config.output_switch)
|
# — no sweep lost at a switch boundary. The producer then only reads tagged
|
||||||
|
# sweeps and must not touch the GPIO lines the collector owns. With mock switches
|
||||||
|
# (dev/tests) we keep the Python-driven path, which is fine where speed and the
|
||||||
|
# in-gap timing do not matter.
|
||||||
|
collector_driven = (
|
||||||
|
config.input_switch.driver_mode == "native"
|
||||||
|
and config.output_switch.driver_mode == "native"
|
||||||
|
)
|
||||||
|
radar = KamilAdcService(
|
||||||
|
config,
|
||||||
|
switch_config_path=str(args.config) if collector_driven else None,
|
||||||
|
)
|
||||||
|
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
|
||||||
|
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
|
||||||
|
logger.info(
|
||||||
|
"Kamil ADC switch control: %s",
|
||||||
|
"collector-driven (in-gap, lossless)" if collector_driven else "producer-driven",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
||||||
@@ -155,18 +181,28 @@ def main() -> int:
|
|||||||
for combo in config.combos:
|
for combo in config.combos:
|
||||||
if stop_requested.is_set():
|
if stop_requested.is_set():
|
||||||
break
|
break
|
||||||
output_switch.switch_to(combo.output)
|
if collector_driven:
|
||||||
input_switch.switch_to(combo.input)
|
# The collector already switched and tagged the sweep; just
|
||||||
if config.runtime.settling_ms > 0:
|
# read the clean capture for this combination.
|
||||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
sweep_start_ns = time.monotonic_ns()
|
||||||
|
sweep = radar.acquire(combo=(combo.input, combo.output))
|
||||||
sweep = radar.acquire()
|
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)
|
||||||
|
# Stamped after switching and settling so the window covers
|
||||||
|
# the sweep alone, not the dead time before it.
|
||||||
|
sweep_start_ns = time.monotonic_ns()
|
||||||
|
sweep = radar.acquire()
|
||||||
traces.append(
|
traces.append(
|
||||||
TraceData(
|
TraceData(
|
||||||
combo=ComboKey(input=combo.input, output=combo.output),
|
combo=ComboKey(input=combo.input, output=combo.output),
|
||||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||||
|
capture_start_ns=sweep_start_ns,
|
||||||
|
capture_end_ns=time.monotonic_ns(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
||||||
@@ -222,10 +258,12 @@ def main() -> int:
|
|||||||
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
||||||
collection_id += 1
|
collection_id += 1
|
||||||
finally:
|
finally:
|
||||||
with suppress(Exception):
|
if output_switch is not None:
|
||||||
output_switch.close()
|
with suppress(Exception):
|
||||||
with suppress(Exception):
|
output_switch.close()
|
||||||
input_switch.close()
|
if input_switch is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
input_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
radar.close()
|
radar.close()
|
||||||
raw_tap_writer.close()
|
raw_tap_writer.close()
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Standalone laser temperature checker process.
|
||||||
|
|
||||||
|
Reads the target setpoints frozen at variation start and tails the JSONL readings
|
||||||
|
channel produced by the monitor. For every reading it validates both lasers and
|
||||||
|
prints a console warning whenever a measured temperature drifts from its target by
|
||||||
|
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
|
||||||
|
fully independent of the monitor and can be started/stopped at any time.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
python -m python_app.scripts.laser_temp_checker
|
||||||
|
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
DEFAULT_READINGS_PATH,
|
||||||
|
DEFAULT_SESSION_PATH,
|
||||||
|
LaserTemperatureChecker,
|
||||||
|
LaserVariationSession,
|
||||||
|
ReadingReader,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("laser_temp_checker")
|
||||||
|
|
||||||
|
_POLL_INTERVAL_S = 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
|
||||||
|
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
||||||
|
help="Session snapshot with target setpoints + tolerance")
|
||||||
|
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
||||||
|
help="JSONL readings channel to tail")
|
||||||
|
parser.add_argument("--tolerance", type=float, default=None,
|
||||||
|
help="Override tolerance in °C (default: from session)")
|
||||||
|
parser.add_argument("--reminder-every", type=int, default=0,
|
||||||
|
help="Repeat a warning every N readings while off target (0=off)")
|
||||||
|
parser.add_argument("--from-start", action="store_true",
|
||||||
|
help="Validate the whole readings file, not just new lines")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
session = LaserVariationSession.load(args.session)
|
||||||
|
if args.tolerance is not None:
|
||||||
|
session.tolerance_c = args.tolerance
|
||||||
|
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
|
||||||
|
logger.info(
|
||||||
|
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
|
||||||
|
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
reader = ReadingReader(args.readings, from_start=args.from_start)
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
def request_stop(_signum: int, _frame: object) -> None:
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, request_stop)
|
||||||
|
signal.signal(signal.SIGTERM, request_stop)
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
for reading in reader.poll():
|
||||||
|
checker.process(reading)
|
||||||
|
stop_event.wait(_POLL_INTERVAL_S)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Standalone laser temperature monitor process.
|
||||||
|
|
||||||
|
Owns the laser serial port, (optionally) starts a current-variation task, then
|
||||||
|
polls the board once per sweep and appends each reading to a JSONL channel that
|
||||||
|
the temperature checker tails. Runs until SIGINT/SIGTERM.
|
||||||
|
|
||||||
|
Examples::
|
||||||
|
|
||||||
|
# Start LD1 current variation from a run config, then monitor:
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config run_config.json --start
|
||||||
|
|
||||||
|
# Monitor a variation that is already running:
|
||||||
|
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.controller import (
|
||||||
|
DEVICE_MAIN_MESSAGE_ID,
|
||||||
|
LaserController,
|
||||||
|
)
|
||||||
|
from python_app.hardware_full.laser_control.exceptions import PortBusyError
|
||||||
|
from python_app.hardware_full.laser_control.models import VariationType
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
DEFAULT_READINGS_PATH,
|
||||||
|
DEFAULT_SESSION_PATH,
|
||||||
|
LaserTemperatureMonitor,
|
||||||
|
LaserVariationSession,
|
||||||
|
ReadingWriter,
|
||||||
|
resolve_period_s,
|
||||||
|
)
|
||||||
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
|
|
||||||
|
logger = logging.getLogger("laser_temp_monitor")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_variation(controller: LaserController, variation) -> None:
|
||||||
|
"""Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot."""
|
||||||
|
controller.reset()
|
||||||
|
controller.set_manual_mode(
|
||||||
|
temp1=variation.static_temp1,
|
||||||
|
temp2=variation.static_temp2,
|
||||||
|
current1=variation.static_current1,
|
||||||
|
current2=variation.static_current2,
|
||||||
|
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||||
|
)
|
||||||
|
controller.start_variation(
|
||||||
|
variation_type=VariationType[variation.variation_type],
|
||||||
|
params={
|
||||||
|
"static_temp1": variation.static_temp1,
|
||||||
|
"static_temp2": variation.static_temp2,
|
||||||
|
"static_current1": variation.static_current1,
|
||||||
|
"static_current2": variation.static_current2,
|
||||||
|
"min_value": variation.min_value,
|
||||||
|
"max_value": variation.max_value,
|
||||||
|
"step": variation.step,
|
||||||
|
"time_step": variation.time_step,
|
||||||
|
"delay_time": variation.delay_time,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep")
|
||||||
|
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
|
||||||
|
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
||||||
|
help="JSONL readings channel to append to")
|
||||||
|
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
||||||
|
help="Session snapshot path (written with --start)")
|
||||||
|
parser.add_argument("--strategy", default="computed",
|
||||||
|
help="'computed' (per sweep) or 'interval:<ms>'")
|
||||||
|
parser.add_argument("--start", action="store_true",
|
||||||
|
help="Send CHANGE_CURRENT_LD1 before monitoring")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
config = RunConfigModel.load_from_path(args.config)
|
||||||
|
laser = config.radar.laser_control
|
||||||
|
variation = laser.variation
|
||||||
|
if variation.variation_type != "CHANGE_CURRENT_LD1":
|
||||||
|
logger.warning(
|
||||||
|
"Only CHANGE_CURRENT_LD1 is supported by firmware; got %s",
|
||||||
|
variation.variation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
period_s = resolve_period_s(
|
||||||
|
args.strategy,
|
||||||
|
min_value=variation.min_value,
|
||||||
|
max_value=variation.max_value,
|
||||||
|
step=variation.step,
|
||||||
|
time_step_us=variation.time_step,
|
||||||
|
delay_time_ms=variation.delay_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
def request_stop(_signum: int, _frame: object) -> None:
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, request_stop)
|
||||||
|
signal.signal(signal.SIGTERM, request_stop)
|
||||||
|
|
||||||
|
controller = LaserController(
|
||||||
|
port=laser.port or None,
|
||||||
|
pi_coeff1_p=laser.pi_coeff1_p,
|
||||||
|
pi_coeff1_i=laser.pi_coeff1_i,
|
||||||
|
pi_coeff2_p=laser.pi_coeff2_p,
|
||||||
|
pi_coeff2_i=laser.pi_coeff2_i,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
controller.connect()
|
||||||
|
except PortBusyError as exc:
|
||||||
|
# Expected, benign conflict: the manual-control UI (or another monitor)
|
||||||
|
# already owns the port. Exit cleanly with guidance, not a traceback.
|
||||||
|
logger.error("%s", exc)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
if args.start:
|
||||||
|
_start_variation(controller, variation)
|
||||||
|
LaserVariationSession(
|
||||||
|
variation_type=variation.variation_type,
|
||||||
|
target_temp1=variation.static_temp1,
|
||||||
|
target_temp2=variation.static_temp2,
|
||||||
|
tolerance_c=variation.temp_tolerance_c,
|
||||||
|
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
||||||
|
).save(args.session)
|
||||||
|
logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session)
|
||||||
|
|
||||||
|
with ReadingWriter(args.readings) as writer:
|
||||||
|
monitor = LaserTemperatureMonitor(
|
||||||
|
controller=controller, writer=writer, period_s=period_s
|
||||||
|
)
|
||||||
|
logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s)
|
||||||
|
monitor.run(stop_event)
|
||||||
|
finally:
|
||||||
|
controller.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -22,7 +22,14 @@ def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes:
|
def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes:
|
||||||
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format."""
|
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format.
|
||||||
|
|
||||||
|
The trailer is appended after the trace blocks so older readers, which stop at
|
||||||
|
the last block, still decode the traces: first the collection capture window,
|
||||||
|
then a per-trace window table (one ``(start_ns, end_ns)`` pair per trace, in
|
||||||
|
trace order). See :func:`python_app.orchestration.shm.decoder.decode_trace_collection`
|
||||||
|
and ``read_trace_collection`` in ``common_cpp/ipc/src/shared_types.cpp``.
|
||||||
|
"""
|
||||||
buffer = bytearray()
|
buffer = bytearray()
|
||||||
buffer.extend(struct.pack("<IQQI", magic, collection.collection_id, collection.monotonic_ns, len(collection.traces)))
|
buffer.extend(struct.pack("<IQQI", magic, collection.collection_id, collection.monotonic_ns, len(collection.traces)))
|
||||||
|
|
||||||
@@ -48,6 +55,11 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
|
|||||||
int(collection.capture_end_ns),
|
int(collection.capture_end_ns),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
buffer.extend(struct.pack("<I", len(collection.traces)))
|
||||||
|
for trace in collection.traces:
|
||||||
|
buffer.extend(
|
||||||
|
struct.pack("<QQ", int(trace.capture_start_ns), int(trace.capture_end_ns))
|
||||||
|
)
|
||||||
return bytes(buffer)
|
return bytes(buffer)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], m
|
|||||||
"capture_start_ns": int(collection.capture_start_ns),
|
"capture_start_ns": int(collection.capture_start_ns),
|
||||||
"capture_end_ns": int(collection.capture_end_ns),
|
"capture_end_ns": int(collection.capture_end_ns),
|
||||||
"trace_count": len(collection.traces),
|
"trace_count": len(collection.traces),
|
||||||
|
# Also in the .bin trailer; repeated here so per-combo timing is
|
||||||
|
# readable without decoding the binary payload.
|
||||||
|
"traces": [
|
||||||
|
{
|
||||||
|
"input": int(trace.combo.input),
|
||||||
|
"output": int(trace.combo.output),
|
||||||
|
"capture_start_ns": int(trace.capture_start_ns),
|
||||||
|
"capture_end_ns": int(trace.capture_end_ns),
|
||||||
|
}
|
||||||
|
for trace in collection.traces
|
||||||
|
],
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
),
|
),
|
||||||
@@ -182,6 +193,10 @@ def save_trace_history_numpy(
|
|||||||
"input": int(trace.combo.input),
|
"input": int(trace.combo.input),
|
||||||
"output": int(trace.combo.output),
|
"output": int(trace.combo.output),
|
||||||
"points": int(freq.size),
|
"points": int(freq.size),
|
||||||
|
# When each combo was measured, which in a switched matrix is
|
||||||
|
# spread across the collection window rather than aligned with it.
|
||||||
|
"capture_start_ns": int(trace.capture_start_ns),
|
||||||
|
"capture_end_ns": int(trace.capture_end_ns),
|
||||||
"freq_file": f"{tag}_freq.npy",
|
"freq_file": f"{tag}_freq.npy",
|
||||||
"s11_file": f"{tag}_s11.npy",
|
"s11_file": f"{tag}_s11.npy",
|
||||||
"s21_file": f"{tag}_s21.npy",
|
"s21_file": f"{tag}_s21.npy",
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ class NpzStore(StoreApi):
|
|||||||
{
|
{
|
||||||
"input": trace.combo.input,
|
"input": trace.combo.input,
|
||||||
"output": trace.combo.output,
|
"output": trace.combo.output,
|
||||||
|
"capture_start_ns": int(trace.capture_start_ns),
|
||||||
|
"capture_end_ns": int(trace.capture_end_ns),
|
||||||
"freq_key": freq_key,
|
"freq_key": freq_key,
|
||||||
"s11_key": s11_key,
|
"s11_key": s11_key,
|
||||||
"s21_key": s21_key,
|
"s21_key": s21_key,
|
||||||
@@ -177,6 +179,8 @@ class NpzStore(StoreApi):
|
|||||||
frequency_hz=freq,
|
frequency_hz=freq,
|
||||||
s11=s11,
|
s11=s11,
|
||||||
s21=s21,
|
s21=s21,
|
||||||
|
capture_start_ns=int(combo.get("capture_start_ns", 0)),
|
||||||
|
capture_end_ns=int(combo.get("capture_end_ns", 0)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class TraceRecord:
|
|||||||
stage_index: int
|
stage_index: int
|
||||||
frequency_hz: np.ndarray
|
frequency_hz: np.ndarray
|
||||||
samples: np.ndarray
|
samples: np.ndarray
|
||||||
|
# End of this trace's own sweep, or 0 when the producer reported no per-trace
|
||||||
|
# timing. Preferred over the collection timestamp for the exported sweep time:
|
||||||
|
# in a switched matrix each combo is measured at a different instant.
|
||||||
|
capture_end_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _normalize_channel(channel: str) -> str:
|
def _normalize_channel(channel: str) -> str:
|
||||||
@@ -85,6 +89,7 @@ def _build_stage_records(
|
|||||||
stage_index=int(stage_index),
|
stage_index=int(stage_index),
|
||||||
frequency_hz=frequency_hz,
|
frequency_hz=frequency_hz,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
|
capture_end_ns=int(trace.capture_end_ns),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return records
|
return records
|
||||||
@@ -137,7 +142,15 @@ def _build_sweep_history(
|
|||||||
|
|
||||||
start_freq_hz = float(base.frequency_hz[0])
|
start_freq_hz = float(base.frequency_hz[0])
|
||||||
stop_freq_hz = float(base.frequency_hz[-1])
|
stop_freq_hz = float(base.frequency_hz[-1])
|
||||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
# Prefer the exported trace's own sweep time: with a switching matrix the
|
||||||
|
# combos of one collection are measured milliseconds apart, so the
|
||||||
|
# collection timestamp misplaces every combo but the last.
|
||||||
|
if base.capture_end_ns > 0:
|
||||||
|
timestamp_sec = float(base.capture_end_ns) / 1_000_000_000.0
|
||||||
|
elif base.monotonic_ns > 0:
|
||||||
|
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0
|
||||||
|
else:
|
||||||
|
timestamp_sec = float(fallback_index)
|
||||||
|
|
||||||
history.append(
|
history.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import unittest
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc.protocol import (
|
from python_app.hardware_full.kamil_adc.protocol import (
|
||||||
|
COMBO_MARKER,
|
||||||
MAIN_MARKER,
|
MAIN_MARKER,
|
||||||
REFERENCE_MARKER,
|
REFERENCE_MARKER,
|
||||||
KamilAdcStreamParser,
|
KamilAdcStreamParser,
|
||||||
@@ -18,6 +19,10 @@ def _boundary() -> bytes:
|
|||||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
||||||
|
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
||||||
|
|
||||||
|
|
||||||
def _main(step: int, real: int, imag: int) -> bytes:
|
def _main(step: int, real: int, imag: int) -> bytes:
|
||||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||||
|
|
||||||
@@ -140,6 +145,40 @@ class KamilAdcStreamParserTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(sweeps), 1)
|
self.assertEqual(len(sweeps), 1)
|
||||||
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
||||||
|
|
||||||
|
def test_untagged_sweep_has_no_combo(self) -> None:
|
||||||
|
parser = KamilAdcStreamParser()
|
||||||
|
(sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
||||||
|
self.assertIsNone(sweep.combo)
|
||||||
|
self.assertFalse(sweep.dirty)
|
||||||
|
|
||||||
|
def test_combo_tag_labels_following_sweep(self) -> None:
|
||||||
|
parser = KamilAdcStreamParser()
|
||||||
|
stream = (
|
||||||
|
_boundary() + _combo(1, 2)
|
||||||
|
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
||||||
|
+ _boundary() + _combo(3, 0, dirty=1)
|
||||||
|
+ _main(1, 20, 0) + _reference(1, 200, 0)
|
||||||
|
+ _boundary()
|
||||||
|
)
|
||||||
|
first, second = parser.feed(stream)
|
||||||
|
self.assertEqual(first.combo, (1, 2))
|
||||||
|
self.assertFalse(first.dirty)
|
||||||
|
self.assertEqual(second.combo, (3, 0))
|
||||||
|
self.assertTrue(second.dirty)
|
||||||
|
|
||||||
|
def test_combo_not_carried_into_untagged_sweep(self) -> None:
|
||||||
|
parser = KamilAdcStreamParser()
|
||||||
|
stream = (
|
||||||
|
_boundary() + _combo(1, 1)
|
||||||
|
+ _main(1, 1, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary() # next sweep has no combo frame
|
||||||
|
+ _main(2, 2, 0) + _reference(2, 2, 0)
|
||||||
|
+ _boundary()
|
||||||
|
)
|
||||||
|
first, second = parser.feed(stream)
|
||||||
|
self.assertEqual(first.combo, (1, 1))
|
||||||
|
self.assertIsNone(second.combo)
|
||||||
|
|
||||||
def test_dtypes(self) -> None:
|
def test_dtypes(self) -> None:
|
||||||
parser = KamilAdcStreamParser()
|
parser = KamilAdcStreamParser()
|
||||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import unittest
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
||||||
from python_app.hardware_full.kamil_adc.protocol import MAIN_MARKER, REFERENCE_MARKER
|
from python_app.hardware_full.kamil_adc.protocol import (
|
||||||
|
COMBO_MARKER,
|
||||||
|
MAIN_MARKER,
|
||||||
|
REFERENCE_MARKER,
|
||||||
|
)
|
||||||
from python_app.models.run_config_model import RunConfigModel
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@ def _reference(step: int, real: int, imag: int) -> bytes:
|
|||||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||||
|
|
||||||
|
|
||||||
|
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
||||||
|
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||||
"""End-to-end tests over a PTY exercising the background reader thread."""
|
"""End-to-end tests over a PTY exercising the background reader thread."""
|
||||||
|
|
||||||
@@ -127,6 +135,41 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
self._close(master_fd, slave_fd, reader)
|
self._close(master_fd, slave_fd, reader)
|
||||||
|
|
||||||
|
def test_read_sweep_for_demuxes_by_combo(self) -> None:
|
||||||
|
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||||
|
try:
|
||||||
|
os.write(
|
||||||
|
master_fd,
|
||||||
|
_boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary(),
|
||||||
|
)
|
||||||
|
# Each combination is served from its own slot, regardless of order.
|
||||||
|
second = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
||||||
|
self.assertEqual(second.main.real.tolist(), [22])
|
||||||
|
self.assertEqual(second.combo, (0, 1))
|
||||||
|
first = reader.read_sweep_for((0, 0), timeout_s=1.0)
|
||||||
|
self.assertEqual(first.main.real.tolist(), [11])
|
||||||
|
finally:
|
||||||
|
self._close(master_fd, slave_fd, reader)
|
||||||
|
|
||||||
|
def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None:
|
||||||
|
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||||
|
try:
|
||||||
|
os.write(
|
||||||
|
master_fd,
|
||||||
|
# A dirty combo (0,1) sweep, then its clean re-take of the same combo.
|
||||||
|
_boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0)
|
||||||
|
+ _boundary(),
|
||||||
|
)
|
||||||
|
sweep = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
||||||
|
# The dirty sweep (99) is dropped; only the clean re-take (42) is served.
|
||||||
|
self.assertEqual(sweep.main.real.tolist(), [42])
|
||||||
|
self.assertFalse(sweep.dirty)
|
||||||
|
finally:
|
||||||
|
self._close(master_fd, slave_fd, reader)
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcConfigTest(unittest.TestCase):
|
class KamilAdcConfigTest(unittest.TestCase):
|
||||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
||||||
@@ -205,6 +248,61 @@ class KamilAdcConfigTest(unittest.TestCase):
|
|||||||
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
||||||
service.close() # must not raise
|
service.close() # must not raise
|
||||||
|
|
||||||
|
def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None:
|
||||||
|
"""After a switch change, drain must skip the configured number of freshly
|
||||||
|
published sweeps before returning, so the next capture is post-switch."""
|
||||||
|
import types
|
||||||
|
|
||||||
|
from python_app.hardware_full.kamil_adc import service as service_module
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
config = RunConfigModel.from_dict(
|
||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "kamil_adc",
|
||||||
|
"driver_mode": "native",
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": tmp_dir,
|
||||||
|
"executable_path": "/bin/sh",
|
||||||
|
"tty_path": "/tmp/ttyADC_test",
|
||||||
|
"sweep_timeout_s": 5.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = KamilAdcService(config)
|
||||||
|
service._reader = types.SimpleNamespace(published_count=10) # type: ignore[assignment]
|
||||||
|
service._process = types.SimpleNamespace(poll=lambda: None) # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Each poll-sleep advances the published count, as the reader thread would.
|
||||||
|
def _advance(_seconds: float) -> None:
|
||||||
|
service._reader.published_count += 1
|
||||||
|
|
||||||
|
with mock.patch.object(service_module.time, "sleep", _advance):
|
||||||
|
service.drain_after_switch(sweeps=3)
|
||||||
|
|
||||||
|
# Started at 10, must have waited for at least 3 more sweeps.
|
||||||
|
self.assertGreaterEqual(service._reader.published_count, 13)
|
||||||
|
|
||||||
|
def test_drain_after_switch_is_noop_when_not_open(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
config = RunConfigModel.from_dict(
|
||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "kamil_adc",
|
||||||
|
"driver_mode": "native",
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": tmp_dir,
|
||||||
|
"executable_path": "/bin/sh",
|
||||||
|
"tty_path": "/tmp/ttyADC_test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
KamilAdcService(config).drain_after_switch() # no reader → must not raise
|
||||||
|
|
||||||
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
config_path = Path(tmp_dir) / "run_config.json"
|
config_path = Path(tmp_dir) / "run_config.json"
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for the laser current-variation temperature monitoring package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.monitoring import (
|
||||||
|
LaserTemperatureChecker,
|
||||||
|
LaserTemperatureMonitor,
|
||||||
|
LaserVariationSession,
|
||||||
|
ReadingReader,
|
||||||
|
ReadingWriter,
|
||||||
|
TemperatureReading,
|
||||||
|
compute_sweep_period_s,
|
||||||
|
resolve_period_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeMeasurements:
|
||||||
|
temp1: float
|
||||||
|
temp2: float
|
||||||
|
temp_ext1: Optional[float] = None
|
||||||
|
temp_ext2: Optional[float] = None
|
||||||
|
current1: Optional[float] = None
|
||||||
|
current2: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeController:
|
||||||
|
"""Returns a queued sequence of measurements, then None."""
|
||||||
|
|
||||||
|
def __init__(self, measurements: List[Optional[_FakeMeasurements]]) -> None:
|
||||||
|
self._queue = list(measurements)
|
||||||
|
|
||||||
|
def get_measurements(self) -> Optional[_FakeMeasurements]:
|
||||||
|
return self._queue.pop(0) if self._queue else None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRoundTripTest(unittest.TestCase):
|
||||||
|
def test_save_then_load_preserves_targets_and_tolerance(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "session.json"
|
||||||
|
LaserVariationSession(
|
||||||
|
variation_type="CHANGE_CURRENT_LD1",
|
||||||
|
target_temp1=28.0,
|
||||||
|
target_temp2=28.9,
|
||||||
|
tolerance_c=0.03,
|
||||||
|
started_at_iso="2026-07-27T12:00:00",
|
||||||
|
).save(path)
|
||||||
|
|
||||||
|
loaded = LaserVariationSession.load(path)
|
||||||
|
|
||||||
|
self.assertEqual(loaded.variation_type, "CHANGE_CURRENT_LD1")
|
||||||
|
self.assertAlmostEqual(loaded.target_temp1, 28.0)
|
||||||
|
self.assertAlmostEqual(loaded.target_temp2, 28.9)
|
||||||
|
self.assertAlmostEqual(loaded.tolerance_c, 0.03)
|
||||||
|
|
||||||
|
def test_load_missing_file_raises(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
LaserVariationSession.load(Path(tmp) / "absent.json")
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingsChannelTest(unittest.TestCase):
|
||||||
|
def _reading(self, seq: int, t1: float = 25.0, t2: float = 25.0) -> TemperatureReading:
|
||||||
|
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
|
||||||
|
|
||||||
|
def test_reader_tails_appended_lines_in_order(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "readings.jsonl"
|
||||||
|
reader = ReadingReader(path) # start at (nonexistent) end
|
||||||
|
with ReadingWriter(path) as writer:
|
||||||
|
writer.write(self._reading(0, 25.0))
|
||||||
|
writer.write(self._reading(1, 26.0))
|
||||||
|
first = list(reader.poll())
|
||||||
|
writer.write(self._reading(2, 27.0))
|
||||||
|
second = list(reader.poll())
|
||||||
|
|
||||||
|
self.assertEqual([r.seq for r in first], [0, 1])
|
||||||
|
self.assertEqual([r.seq for r in second], [2])
|
||||||
|
self.assertAlmostEqual(first[1].temp1, 26.0)
|
||||||
|
|
||||||
|
def test_partial_trailing_line_is_buffered_until_newline(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "readings.jsonl"
|
||||||
|
path.write_text('{"seq":0,"mono_ns":0,"temp1":25.0,"temp2":25.0}\n{"seq":1,"mono',
|
||||||
|
encoding="utf-8")
|
||||||
|
reader = ReadingReader(path, from_start=True)
|
||||||
|
first = list(reader.poll())
|
||||||
|
# Complete the truncated line.
|
||||||
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write('_ns":1,"temp1":26.0,"temp2":26.0}\n')
|
||||||
|
second = list(reader.poll())
|
||||||
|
|
||||||
|
self.assertEqual([r.seq for r in first], [0])
|
||||||
|
self.assertEqual([r.seq for r in second], [1])
|
||||||
|
|
||||||
|
|
||||||
|
class SweepPeriodTest(unittest.TestCase):
|
||||||
|
def test_compute_sweep_period_matches_formula(self) -> None:
|
||||||
|
# (35-33)/0.05 = 40 -> 41 points; per point = 10ms + 50us = 0.01005s.
|
||||||
|
period = compute_sweep_period_s(33.0, 35.0, 0.05, time_step_us=50, delay_time_ms=10)
|
||||||
|
self.assertAlmostEqual(period, 41 * 0.01005, places=6)
|
||||||
|
|
||||||
|
def test_resolve_interval_strategy(self) -> None:
|
||||||
|
period = resolve_period_s(
|
||||||
|
"interval:250", min_value=33.0, max_value=35.0, step=0.05,
|
||||||
|
time_step_us=50, delay_time_ms=10,
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(period, 0.25)
|
||||||
|
|
||||||
|
def test_resolve_rejects_unknown_strategy(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
resolve_period_s("bogus", min_value=0, max_value=1, step=0.1,
|
||||||
|
time_step_us=50, delay_time_ms=10)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorTest(unittest.TestCase):
|
||||||
|
def test_read_once_maps_measurement_fields(self) -> None:
|
||||||
|
controller = _FakeController([_FakeMeasurements(
|
||||||
|
temp1=28.01, temp2=28.9, temp_ext1=22.0, temp_ext2=23.0,
|
||||||
|
current1=33.0, current2=35.0,
|
||||||
|
)])
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with ReadingWriter(Path(tmp) / "r.jsonl") as writer:
|
||||||
|
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||||
|
reading = monitor.read_once(7)
|
||||||
|
|
||||||
|
assert reading is not None
|
||||||
|
self.assertEqual(reading.seq, 7)
|
||||||
|
self.assertAlmostEqual(reading.temp1, 28.01)
|
||||||
|
self.assertAlmostEqual(reading.temp_ext1, 22.0)
|
||||||
|
self.assertAlmostEqual(reading.current2, 35.0)
|
||||||
|
|
||||||
|
def test_run_publishes_until_stopped(self) -> None:
|
||||||
|
controller = _FakeController([
|
||||||
|
_FakeMeasurements(28.0, 28.9),
|
||||||
|
_FakeMeasurements(28.0, 28.9),
|
||||||
|
])
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
class _OneShotWriter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.written: List[TemperatureReading] = []
|
||||||
|
|
||||||
|
def write(self, reading: TemperatureReading) -> None:
|
||||||
|
self.written.append(reading)
|
||||||
|
stop.set() # stop after the first publish
|
||||||
|
|
||||||
|
writer = _OneShotWriter()
|
||||||
|
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||||
|
monitor.run(stop)
|
||||||
|
|
||||||
|
self.assertEqual(len(writer.written), 1)
|
||||||
|
self.assertEqual(writer.written[0].seq, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckerTest(unittest.TestCase):
|
||||||
|
def _checker(self, **kwargs: object) -> LaserTemperatureChecker:
|
||||||
|
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
|
||||||
|
tolerance_c=0.03, **kwargs)
|
||||||
|
|
||||||
|
def _reading(self, t1: float, t2: float, seq: int = 0) -> TemperatureReading:
|
||||||
|
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
|
||||||
|
|
||||||
|
def test_laser1_off_target_warns_once_for_laser1(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=28.05, t2=28.9)) # laser1 off by 0.05
|
||||||
|
self.assertEqual([d.laser for d in warned], [1])
|
||||||
|
|
||||||
|
def test_within_tolerance_no_warning(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=28.01, t2=28.9)) # 0.01 < 0.03
|
||||||
|
self.assertEqual(warned, [])
|
||||||
|
|
||||||
|
def test_boundary_equal_tolerance_is_ok(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=28.03, t2=28.9)) # |Δ|==tol -> within
|
||||||
|
self.assertEqual(warned, [])
|
||||||
|
|
||||||
|
def test_both_lasers_off_target_warn_independently(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
warned = checker.process(self._reading(t1=27.9, t2=29.0))
|
||||||
|
self.assertEqual(sorted(d.laser for d in warned), [1, 2])
|
||||||
|
|
||||||
|
def test_persistent_mismatch_warns_once_then_silent(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
first = checker.process(self._reading(t1=28.1, t2=28.9, seq=0))
|
||||||
|
second = checker.process(self._reading(t1=28.1, t2=28.9, seq=1))
|
||||||
|
self.assertEqual([d.laser for d in first], [1])
|
||||||
|
self.assertEqual(second, []) # no reminder configured
|
||||||
|
|
||||||
|
def test_reminder_repeats_warning(self) -> None:
|
||||||
|
checker = self._checker(reminder_every=2)
|
||||||
|
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # initial warn
|
||||||
|
self.assertEqual(checker.process(self._reading(t1=28.1, t2=28.9, seq=1)), [])
|
||||||
|
again = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # reminder
|
||||||
|
self.assertEqual([d.laser for d in again], [1])
|
||||||
|
|
||||||
|
def test_recovery_clears_mismatch_state(self) -> None:
|
||||||
|
checker = self._checker()
|
||||||
|
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # warn
|
||||||
|
checker.process(self._reading(t1=28.0, t2=28.9, seq=1)) # recover (info, no warn)
|
||||||
|
rewarn = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # warns again
|
||||||
|
self.assertEqual([d.laser for d in rewarn], [1])
|
||||||
|
|
||||||
|
def test_from_session_uses_session_targets(self) -> None:
|
||||||
|
session = LaserVariationSession(
|
||||||
|
variation_type="CHANGE_CURRENT_LD1",
|
||||||
|
target_temp1=30.0, target_temp2=31.0, tolerance_c=0.03,
|
||||||
|
)
|
||||||
|
checker = LaserTemperatureChecker.from_session(session)
|
||||||
|
warned = checker.process(self._reading(t1=30.1, t2=31.0))
|
||||||
|
self.assertEqual([d.laser for d in warned], [1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Unit tests for the bounded GUI log-panel buffer.
|
||||||
|
|
||||||
|
The buffer decouples logging handlers (any thread, potentially very chatty at
|
||||||
|
DEBUG) from the GUI: records are batched by a flush timer instead of posting one
|
||||||
|
queued Qt event per record, and overflow drops the oldest records with a count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from python_app.gui.app_window import _PanelLogBuffer
|
||||||
|
|
||||||
|
|
||||||
|
class PanelLogBufferTest(unittest.TestCase):
|
||||||
|
"""Bounded capacity, oldest-first eviction, and accurate drop accounting."""
|
||||||
|
|
||||||
|
def test_drain_returns_entries_in_order_and_clears(self) -> None:
|
||||||
|
buffer = _PanelLogBuffer()
|
||||||
|
buffer.append("INFO", "first", None, None)
|
||||||
|
buffer.append("WARN", "second", "details", "key")
|
||||||
|
|
||||||
|
entries, dropped_count = buffer.drain()
|
||||||
|
|
||||||
|
self.assertEqual(dropped_count, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
entries,
|
||||||
|
[("INFO", "first", None, None), ("WARN", "second", "details", "key")],
|
||||||
|
)
|
||||||
|
self.assertEqual(buffer.drain(), ([], 0))
|
||||||
|
|
||||||
|
def test_overflow_drops_oldest_and_counts(self) -> None:
|
||||||
|
buffer = _PanelLogBuffer()
|
||||||
|
overflow = 100
|
||||||
|
total = _PanelLogBuffer._CAPACITY + overflow
|
||||||
|
for index in range(total):
|
||||||
|
buffer.append("DEBUG", f"m{index}", None, None)
|
||||||
|
|
||||||
|
entries, dropped_count = buffer.drain()
|
||||||
|
|
||||||
|
self.assertEqual(dropped_count, overflow)
|
||||||
|
self.assertEqual(len(entries), _PanelLogBuffer._CAPACITY)
|
||||||
|
self.assertEqual(entries[0][1], f"m{overflow}")
|
||||||
|
self.assertEqual(entries[-1][1], f"m{total - 1}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -39,12 +39,19 @@ from python_app.orchestration.shm.ring_writer import ShmRingWriter
|
|||||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
||||||
|
|
||||||
|
|
||||||
def _trace(in_pos: int, out_pos: int, n: int) -> TraceData:
|
def _trace(in_pos: int, out_pos: int, n: int, *, capture_ns: tuple[int, int] = (0, 0)) -> TraceData:
|
||||||
"""Build a trace with float32-exact data so round-trips compare exactly."""
|
"""Build a trace with float32-exact data so round-trips compare exactly."""
|
||||||
freq = np.arange(n, dtype=np.float32) + 1.0
|
freq = np.arange(n, dtype=np.float32) + 1.0
|
||||||
s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||||
s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||||
return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21)
|
return TraceData(
|
||||||
|
combo=ComboKey(input=in_pos, output=out_pos),
|
||||||
|
frequency_hz=freq,
|
||||||
|
s11=s11,
|
||||||
|
s21=s21,
|
||||||
|
capture_start_ns=capture_ns[0],
|
||||||
|
capture_end_ns=capture_ns[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TraceCollectionRoundTripTest(unittest.TestCase):
|
class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||||
@@ -52,7 +59,7 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
|||||||
collection = SweepCollection(
|
collection = SweepCollection(
|
||||||
collection_id=7,
|
collection_id=7,
|
||||||
monotonic_ns=123,
|
monotonic_ns=123,
|
||||||
traces=[_trace(0, 0, 4), _trace(3, 1, 2)],
|
traces=[_trace(0, 0, 4, capture_ns=(11, 13)), _trace(3, 1, 2, capture_ns=(15, 19))],
|
||||||
capture_start_ns=10,
|
capture_start_ns=10,
|
||||||
capture_end_ns=20,
|
capture_end_ns=20,
|
||||||
)
|
)
|
||||||
@@ -66,6 +73,10 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
|||||||
self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz))
|
self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz))
|
||||||
self.assertTrue(np.array_equal(got.s11, original.s11))
|
self.assertTrue(np.array_equal(got.s11, original.s11))
|
||||||
self.assertTrue(np.array_equal(got.s21, original.s21))
|
self.assertTrue(np.array_equal(got.s21, original.s21))
|
||||||
|
self.assertEqual(
|
||||||
|
(got.capture_start_ns, got.capture_end_ns),
|
||||||
|
(original.capture_start_ns, original.capture_end_ns),
|
||||||
|
)
|
||||||
|
|
||||||
def test_raw_round_trips(self) -> None:
|
def test_raw_round_trips(self) -> None:
|
||||||
self._assert_round_trips(RAW_MAGIC)
|
self._assert_round_trips(RAW_MAGIC)
|
||||||
@@ -78,6 +89,34 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
|||||||
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
|
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
|
||||||
self.assertEqual(decoded.traces, [])
|
self.assertEqual(decoded.traces, [])
|
||||||
|
|
||||||
|
def test_payload_without_per_trace_window_table_still_decodes(self) -> None:
|
||||||
|
# A producer built before per-trace timing stops after the collection
|
||||||
|
# window; its traces must still decode, with the timestamps left at zero.
|
||||||
|
collection = SweepCollection(
|
||||||
|
collection_id=4,
|
||||||
|
monotonic_ns=5,
|
||||||
|
traces=[_trace(1, 0, 3, capture_ns=(7, 9))],
|
||||||
|
capture_start_ns=6,
|
||||||
|
capture_end_ns=10,
|
||||||
|
)
|
||||||
|
full = serialize_trace_collection(collection, RAW_MAGIC)
|
||||||
|
legacy = full[: -(4 + 16 * len(collection.traces))]
|
||||||
|
|
||||||
|
decoded = decode_trace_collection(legacy, RAW_MAGIC)
|
||||||
|
self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (6, 10))
|
||||||
|
self.assertEqual(len(decoded.traces), 1)
|
||||||
|
self.assertEqual((decoded.traces[0].capture_start_ns, decoded.traces[0].capture_end_ns), (0, 0))
|
||||||
|
|
||||||
|
def test_per_trace_window_count_mismatch_is_rejected(self) -> None:
|
||||||
|
collection = SweepCollection(
|
||||||
|
collection_id=4, monotonic_ns=5, traces=[_trace(1, 0, 3, capture_ns=(7, 9))]
|
||||||
|
)
|
||||||
|
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||||
|
# Overwrite the window-table count (u32 before the single 16-byte pair).
|
||||||
|
corrupt = payload[:-20] + struct.pack("<I", 2) + payload[-16:]
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||||
|
|
||||||
|
|
||||||
class ResultCollectionRoundTripTest(unittest.TestCase):
|
class ResultCollectionRoundTripTest(unittest.TestCase):
|
||||||
def test_all_payload_kinds_round_trip(self) -> None:
|
def test_all_payload_kinds_round_trip(self) -> None:
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ class WebControllerTest(unittest.TestCase):
|
|||||||
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
||||||
received: list[dict] = []
|
received: list[dict] = []
|
||||||
self.controller.apply_settings_requested.connect(received.append)
|
self.controller.apply_settings_requested.connect(received.append)
|
||||||
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
|
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
|
||||||
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
|
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
|
||||||
self.assertIsInstance(out, list)
|
self.assertIsInstance(out, list)
|
||||||
|
|
||||||
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Unit tests for switch-widened matrix capture.
|
||||||
|
|
||||||
|
Cover the targeted single-step acquisition on ``SwitchedMatrixRadarService`` and
|
||||||
|
verify the manual per-combo capture workflow uses it instead of sweeping the full
|
||||||
|
widened matrix (the regression that froze the GUI for the whole matrix per click).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||||
|
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
|
||||||
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
|
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||||
|
|
||||||
|
_INNER_INPUTS = 4
|
||||||
|
_INNER_OUTPUTS = 2
|
||||||
|
_POINTS = 8
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInnerMatrixRadar:
|
||||||
|
"""Matrix radar stub emitting the canonical 2x4 combo set per acquisition."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.acquire_count = 0
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def configure(self, sweep) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recover(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
|
||||||
|
self.acquire_count += 1
|
||||||
|
frequency_hz = np.linspace(1e6, 2e6, _POINTS, dtype=np.float32)
|
||||||
|
traces = [
|
||||||
|
TraceData(
|
||||||
|
combo=ComboKey(input=input_pos, output=output_pos),
|
||||||
|
frequency_hz=frequency_hz,
|
||||||
|
s11=np.full(_POINTS, complex(self.acquire_count, 0), dtype=np.complex64),
|
||||||
|
s21=np.full(_POINTS, complex(input_pos, output_pos), dtype=np.complex64),
|
||||||
|
)
|
||||||
|
for output_pos in range(_INNER_OUTPUTS)
|
||||||
|
for input_pos in range(_INNER_INPUTS)
|
||||||
|
]
|
||||||
|
return SweepCollection(
|
||||||
|
collection_id=int(collection_id),
|
||||||
|
monotonic_ns=time.monotonic_ns(),
|
||||||
|
traces=traces,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSwitch:
|
||||||
|
"""Switch stub recording every position it is driven to."""
|
||||||
|
|
||||||
|
def __init__(self, positions: int) -> None:
|
||||||
|
self.positions = positions
|
||||||
|
self.switched_to: list[int] = []
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def position_count(self) -> int:
|
||||||
|
return self.positions
|
||||||
|
|
||||||
|
def switch_to(self, position: int) -> None:
|
||||||
|
self.switched_to.append(int(position))
|
||||||
|
|
||||||
|
|
||||||
|
def _switched_service(input_steps: int = 3) -> tuple[SwitchedMatrixRadarService, _FakeInnerMatrixRadar, _FakeSwitch]:
|
||||||
|
inner = _FakeInnerMatrixRadar()
|
||||||
|
input_switch = _FakeSwitch(input_steps)
|
||||||
|
service = SwitchedMatrixRadarService(
|
||||||
|
inner=inner,
|
||||||
|
output_switch=None,
|
||||||
|
input_switch=input_switch,
|
||||||
|
inner_output_positions=_INNER_OUTPUTS,
|
||||||
|
inner_input_positions=_INNER_INPUTS,
|
||||||
|
settling_ms=0,
|
||||||
|
)
|
||||||
|
return service, inner, input_switch
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchedMatrixComboAcquisitionTest(unittest.TestCase):
|
||||||
|
"""acquire_combo_collection must acquire exactly one physical switch step."""
|
||||||
|
|
||||||
|
def test_acquires_only_the_step_containing_the_combo(self) -> None:
|
||||||
|
service, inner, input_switch = _switched_service(input_steps=3)
|
||||||
|
|
||||||
|
# Widened input 9 lives in physical step 9 // 4 = 2.
|
||||||
|
collection = service.acquire_combo_collection(input_pos=9, output_pos=1)
|
||||||
|
|
||||||
|
self.assertEqual(inner.acquire_count, 1)
|
||||||
|
self.assertEqual(input_switch.switched_to, [2])
|
||||||
|
self.assertEqual(len(collection.traces), _INNER_INPUTS * _INNER_OUTPUTS)
|
||||||
|
combos = {(trace.combo.input, trace.combo.output) for trace in collection.traces}
|
||||||
|
self.assertIn((9, 1), combos)
|
||||||
|
# Every trace of the step is remapped into the widened axis of that step.
|
||||||
|
self.assertEqual(
|
||||||
|
combos,
|
||||||
|
{(2 * _INNER_INPUTS + i, o) for i in range(_INNER_INPUTS) for o in range(_INNER_OUTPUTS)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_out_of_range_combo(self) -> None:
|
||||||
|
service, _inner, _input_switch = _switched_service(input_steps=3)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
service.acquire_combo_collection(input_pos=12, output_pos=0)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
service.acquire_combo_collection(input_pos=0, output_pos=2)
|
||||||
|
|
||||||
|
def test_full_collection_still_covers_widened_matrix_in_canonical_order(self) -> None:
|
||||||
|
service, inner, input_switch = _switched_service(input_steps=3)
|
||||||
|
|
||||||
|
collection = service.acquire_collection(collection_id=7)
|
||||||
|
|
||||||
|
self.assertEqual(inner.acquire_count, 3)
|
||||||
|
self.assertEqual(input_switch.switched_to, [0, 1, 2])
|
||||||
|
expected_combos = [
|
||||||
|
(input_pos, output_pos)
|
||||||
|
for output_pos in range(_INNER_OUTPUTS)
|
||||||
|
for input_pos in range(3 * _INNER_INPUTS)
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[(trace.combo.input, trace.combo.output) for trace in collection.traces],
|
||||||
|
expected_combos,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_each_switch_step_stamps_its_traces_with_its_own_capture_window(self) -> None:
|
||||||
|
# The whole point of per-trace timing: three switch steps are measured one
|
||||||
|
# after another, so their traces must NOT all share the collection window.
|
||||||
|
service, _inner, _input_switch = _switched_service(input_steps=3)
|
||||||
|
|
||||||
|
collection = service.acquire_collection(collection_id=7)
|
||||||
|
|
||||||
|
windows_by_step: dict[int, set[tuple[int, int]]] = {}
|
||||||
|
for trace in collection.traces:
|
||||||
|
step = int(trace.combo.input) // _INNER_INPUTS
|
||||||
|
windows_by_step.setdefault(step, set()).add(
|
||||||
|
(int(trace.capture_start_ns), int(trace.capture_end_ns))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(sorted(windows_by_step), [0, 1, 2])
|
||||||
|
for step, windows in windows_by_step.items():
|
||||||
|
self.assertEqual(len(windows), 1, f"step {step} traces disagree on their window")
|
||||||
|
start_ns, end_ns = next(iter(windows))
|
||||||
|
self.assertGreater(start_ns, 0)
|
||||||
|
self.assertGreaterEqual(end_ns, start_ns)
|
||||||
|
# Each step's window sits inside the collection's.
|
||||||
|
self.assertGreaterEqual(start_ns, collection.capture_start_ns)
|
||||||
|
self.assertLessEqual(end_ns, collection.capture_end_ns)
|
||||||
|
|
||||||
|
# Steps are strictly ordered in time — the whole reason the collection-level
|
||||||
|
# window cannot stand in for a per-combo timestamp.
|
||||||
|
step_starts = [next(iter(windows_by_step[step]))[0] for step in sorted(windows_by_step)]
|
||||||
|
self.assertEqual(step_starts, sorted(step_starts))
|
||||||
|
self.assertGreater(len(set(step_starts)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualComboCaptureUsesTargetedAcquisitionTest(unittest.TestCase):
|
||||||
|
"""The per-combo capture session must not sweep the full widened matrix."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _switched_mock_config() -> RunConfigModel:
|
||||||
|
config = RunConfigModel()
|
||||||
|
config.radar.model = RunConfigModel.LIBREVNA_MULTI_MODEL
|
||||||
|
config.radar.driver_mode = "mock"
|
||||||
|
config.radar.multi_device.slave_serials = ["SLAVE_A", "SLAVE_B"]
|
||||||
|
config.radar.multi_device.input_switch_positions = 3
|
||||||
|
config.apply_device_model_constraints()
|
||||||
|
return config
|
||||||
|
|
||||||
|
def test_manual_capture_runs_one_inner_collection_per_median_sweep(self) -> None:
|
||||||
|
config = self._switched_mock_config()
|
||||||
|
session = SequentialCaptureSession(
|
||||||
|
config=config,
|
||||||
|
kind="s21_calibration",
|
||||||
|
set_name="targeted_test",
|
||||||
|
median_sweep_count=2,
|
||||||
|
)
|
||||||
|
with mock.patch.object(
|
||||||
|
MultiDeviceLibreVnaService,
|
||||||
|
"acquire_collection",
|
||||||
|
autospec=True,
|
||||||
|
side_effect=MultiDeviceLibreVnaService.acquire_collection,
|
||||||
|
) as inner_acquire:
|
||||||
|
session.open()
|
||||||
|
try:
|
||||||
|
trace = session.capture_current_combo()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
first_combo = config.combos[0]
|
||||||
|
self.assertEqual(
|
||||||
|
(trace.combo.input, trace.combo.output),
|
||||||
|
(first_combo.input, first_combo.output),
|
||||||
|
)
|
||||||
|
# 2 median sweeps of ONE physical step — not 2 x 3 full-matrix steps.
|
||||||
|
self.assertEqual(inner_acquire.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,6 +19,7 @@ from python_app.workflows.radar_config_variants import RadarConfigVariant
|
|||||||
from python_app.workflows.sequential_capture_workflow import (
|
from python_app.workflows.sequential_capture_workflow import (
|
||||||
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
|
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
|
||||||
SequentialCaptureState,
|
SequentialCaptureState,
|
||||||
|
acquire_matrix_combo_collection,
|
||||||
combine_collections_via_median,
|
combine_collections_via_median,
|
||||||
combine_traces_via_median,
|
combine_traces_via_median,
|
||||||
select_trace_for_combo,
|
select_trace_for_combo,
|
||||||
@@ -192,20 +193,27 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._radar.configure(variant.config.radar.sweep)
|
self._radar.configure(variant.config.radar.sweep)
|
||||||
if self._base_config.runtime.settling_ms > 0:
|
if self._base_config.runtime.settling_ms > 0:
|
||||||
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
||||||
collections: list[SweepCollection] = []
|
|
||||||
for _ in range(self._median_sweep_count):
|
|
||||||
collection = self._radar.acquire_collection(collection_id=1)
|
|
||||||
if not collection.traces:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Matrix radar variant {variant.display_name} returned no traces"
|
|
||||||
)
|
|
||||||
collections.append(collection)
|
|
||||||
if self._manual_matrix_radar_capture:
|
if self._manual_matrix_radar_capture:
|
||||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
# Only this combo is kept, so acquire the smallest collection
|
||||||
|
# that contains it instead of the full (switch-widened) matrix.
|
||||||
|
per_sweep_traces = [
|
||||||
|
select_trace_for_combo(
|
||||||
|
acquire_matrix_combo_collection(self._radar, combo), combo
|
||||||
|
)
|
||||||
|
for _ in range(self._median_sweep_count)
|
||||||
|
]
|
||||||
trace = combine_traces_via_median(per_sweep_traces)
|
trace = combine_traces_via_median(per_sweep_traces)
|
||||||
pending_traces_by_radar_key[variant.radar_key] = [trace]
|
pending_traces_by_radar_key[variant.radar_key] = [trace]
|
||||||
display_traces.append(trace)
|
display_traces.append(trace)
|
||||||
else:
|
else:
|
||||||
|
collections: list[SweepCollection] = []
|
||||||
|
for _ in range(self._median_sweep_count):
|
||||||
|
collection = self._radar.acquire_collection(collection_id=1)
|
||||||
|
if not collection.traces:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Matrix radar variant {variant.display_name} returned no traces"
|
||||||
|
)
|
||||||
|
collections.append(collection)
|
||||||
combined_collection = combine_collections_via_median(collections)
|
combined_collection = combine_collections_via_median(collections)
|
||||||
pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces)
|
pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces)
|
||||||
display_traces.append(combined_collection.traces[-1])
|
display_traces.append(combined_collection.traces[-1])
|
||||||
@@ -224,6 +232,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
||||||
sweep_traces: list[TraceData] = []
|
sweep_traces: list[TraceData] = []
|
||||||
for _ in range(self._median_sweep_count):
|
for _ in range(self._median_sweep_count):
|
||||||
|
sweep_start_ns = time.monotonic_ns()
|
||||||
sweep = self._radar.acquire()
|
sweep = self._radar.acquire()
|
||||||
sweep_traces.append(
|
sweep_traces.append(
|
||||||
TraceData(
|
TraceData(
|
||||||
@@ -231,6 +240,8 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||||
|
capture_start_ns=sweep_start_ns,
|
||||||
|
capture_end_ns=time.monotonic_ns(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
trace = combine_traces_via_median(sweep_traces)
|
trace = combine_traces_via_median(sweep_traces)
|
||||||
|
|||||||
@@ -156,20 +156,27 @@ class SequentialCaptureSession:
|
|||||||
raise RuntimeError("Capture session is already complete")
|
raise RuntimeError("Capture session is already complete")
|
||||||
|
|
||||||
if self._is_matrix_radar:
|
if self._is_matrix_radar:
|
||||||
collections: list[SweepCollection] = []
|
|
||||||
for _ in range(self._median_sweep_count):
|
|
||||||
collection = self._radar.acquire_collection(collection_id=1)
|
|
||||||
if not collection.traces:
|
|
||||||
raise RuntimeError("Matrix radar capture returned no traces")
|
|
||||||
collections.append(collection)
|
|
||||||
if self._manual_matrix_radar_capture:
|
if self._manual_matrix_radar_capture:
|
||||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
# Only this combo is kept, so acquire the smallest collection that
|
||||||
|
# contains it instead of the full (switch-widened) matrix.
|
||||||
|
per_sweep_traces = [
|
||||||
|
select_trace_for_combo(
|
||||||
|
acquire_matrix_combo_collection(self._radar, combo), combo
|
||||||
|
)
|
||||||
|
for _ in range(self._median_sweep_count)
|
||||||
|
]
|
||||||
trace = combine_traces_via_median(per_sweep_traces)
|
trace = combine_traces_via_median(per_sweep_traces)
|
||||||
self._traces.append(trace)
|
self._traces.append(trace)
|
||||||
self._next_index += 1
|
self._next_index += 1
|
||||||
logger.debug("Captured matrix combo input=%d output=%d", combo.input, combo.output)
|
logger.debug("Captured matrix combo input=%d output=%d", combo.input, combo.output)
|
||||||
return trace
|
return trace
|
||||||
|
|
||||||
|
collections: list[SweepCollection] = []
|
||||||
|
for _ in range(self._median_sweep_count):
|
||||||
|
collection = self._radar.acquire_collection(collection_id=1)
|
||||||
|
if not collection.traces:
|
||||||
|
raise RuntimeError("Matrix radar capture returned no traces")
|
||||||
|
collections.append(collection)
|
||||||
combined_collection = combine_collections_via_median(collections)
|
combined_collection = combine_collections_via_median(collections)
|
||||||
self._traces.extend(combined_collection.traces)
|
self._traces.extend(combined_collection.traces)
|
||||||
self._next_index = len(self._combos)
|
self._next_index = len(self._combos)
|
||||||
@@ -183,8 +190,19 @@ class SequentialCaptureSession:
|
|||||||
if self._config.runtime.settling_ms > 0:
|
if self._config.runtime.settling_ms > 0:
|
||||||
time.sleep(self._config.runtime.settling_ms / 1000.0)
|
time.sleep(self._config.runtime.settling_ms / 1000.0)
|
||||||
|
|
||||||
|
# A free-running streaming radar (Kamil ADC) keeps a sweep captured in the
|
||||||
|
# previous combination buffered, and may have a transition-straddling sweep
|
||||||
|
# in flight. Drop those so this capture holds data from the new switch state
|
||||||
|
# — otherwise the trace is labelled with this combo but carries the previous
|
||||||
|
# one's data (an off-by-one across the sequence). Discrete radars (LibreVNA)
|
||||||
|
# acquire a fresh sweep per call and expose no such method, so skip them.
|
||||||
|
drain_after_switch = getattr(self._radar, "drain_after_switch", None)
|
||||||
|
if callable(drain_after_switch):
|
||||||
|
drain_after_switch()
|
||||||
|
|
||||||
sweep_traces: list[TraceData] = []
|
sweep_traces: list[TraceData] = []
|
||||||
for _ in range(self._median_sweep_count):
|
for _ in range(self._median_sweep_count):
|
||||||
|
sweep_start_ns = time.monotonic_ns()
|
||||||
sweep = self._radar.acquire()
|
sweep = self._radar.acquire()
|
||||||
sweep_traces.append(
|
sweep_traces.append(
|
||||||
TraceData(
|
TraceData(
|
||||||
@@ -192,6 +210,8 @@ class SequentialCaptureSession:
|
|||||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||||
|
capture_start_ns=sweep_start_ns,
|
||||||
|
capture_end_ns=time.monotonic_ns(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
trace = combine_traces_via_median(sweep_traces)
|
trace = combine_traces_via_median(sweep_traces)
|
||||||
@@ -291,6 +311,32 @@ class SequentialCaptureSession:
|
|||||||
return self._combos[self._next_index]
|
return self._combos[self._next_index]
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_matrix_combo_collection(
|
||||||
|
radar: MatrixRadarService,
|
||||||
|
combo: ComboModel,
|
||||||
|
collection_id: int = 1,
|
||||||
|
) -> SweepCollection:
|
||||||
|
"""Acquire the smallest matrix collection that contains one combo.
|
||||||
|
|
||||||
|
A switch-widened matrix radar (``SwitchedMatrixRadarService``) can acquire just
|
||||||
|
the physical switch step carrying the combo, which is several times faster than
|
||||||
|
the full matrix and keeps the per-combo capture UI responsive. Plain matrix
|
||||||
|
radars expose only full-matrix acquisition, so they fall back to it.
|
||||||
|
"""
|
||||||
|
acquire_combo = getattr(radar, "acquire_combo_collection", None)
|
||||||
|
if callable(acquire_combo):
|
||||||
|
collection = acquire_combo(
|
||||||
|
input_pos=int(combo.input),
|
||||||
|
output_pos=int(combo.output),
|
||||||
|
collection_id=collection_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
collection = radar.acquire_collection(collection_id=collection_id)
|
||||||
|
if not collection.traces:
|
||||||
|
raise RuntimeError("Matrix radar capture returned no traces")
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> TraceData:
|
def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> TraceData:
|
||||||
"""Return the trace matching a virtual combo from a full multi-device capture."""
|
"""Return the trace matching a virtual combo from a full multi-device capture."""
|
||||||
for trace in collection.traces:
|
for trace in collection.traces:
|
||||||
@@ -342,11 +388,16 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
|
|||||||
s21_median = (
|
s21_median = (
|
||||||
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
|
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
|
||||||
).astype(np.complex64)
|
).astype(np.complex64)
|
||||||
|
# The median is built from every input sweep, so its window spans all of them.
|
||||||
|
capture_starts = [int(t.capture_start_ns) for t in traces if int(t.capture_start_ns) > 0]
|
||||||
|
capture_ends = [int(t.capture_end_ns) for t in traces if int(t.capture_end_ns) > 0]
|
||||||
return TraceData(
|
return TraceData(
|
||||||
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
|
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
|
||||||
frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32),
|
frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32),
|
||||||
s11=s11_median,
|
s11=s11_median,
|
||||||
s21=s21_median,
|
s21=s21_median,
|
||||||
|
capture_start_ns=min(capture_starts) if capture_starts else 0,
|
||||||
|
capture_end_ns=max(capture_ends) if capture_ends else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -414,7 +414,7 @@
|
|||||||
"min_depth_m": 2.0,
|
"min_depth_m": 2.0,
|
||||||
"max_depth_m": 14.0,
|
"max_depth_m": 14.0,
|
||||||
"range_comp_power": 0.1,
|
"range_comp_power": 0.1,
|
||||||
"angle_comp_power": 0.0,
|
"object_min_frac": 0.7,
|
||||||
"score_mode": "combined",
|
"score_mode": "combined",
|
||||||
"motion_mode": "int_minus",
|
"motion_mode": "int_minus",
|
||||||
"look_angle_deg": 0.0,
|
"look_angle_deg": 0.0,
|
||||||
@@ -423,6 +423,7 @@
|
|||||||
"ignore_socket_speed_enabled": false,
|
"ignore_socket_speed_enabled": false,
|
||||||
"max_detected_objects_to_draw": 5,
|
"max_detected_objects_to_draw": 5,
|
||||||
"draw_top_m_objects": 2,
|
"draw_top_m_objects": 2,
|
||||||
|
"object_approach_min_frames": 3,
|
||||||
"start_freq_mhz": 3000.0,
|
"start_freq_mhz": 3000.0,
|
||||||
"stop_freq_mhz": 6000.0,
|
"stop_freq_mhz": 6000.0,
|
||||||
"background_subtract_enabled": true,
|
"background_subtract_enabled": true,
|
||||||
@@ -430,7 +431,6 @@
|
|||||||
"remove_sidelobe_objects_enabled": false,
|
"remove_sidelobe_objects_enabled": false,
|
||||||
"imaging_plane_y_m": 0.0,
|
"imaging_plane_y_m": 0.0,
|
||||||
"render_mode": "heatmap",
|
"render_mode": "heatmap",
|
||||||
"min_visible_score": 0.0,
|
|
||||||
"visible_x_min_m": -2.0,
|
"visible_x_min_m": -2.0,
|
||||||
"visible_x_max_m": 2.0,
|
"visible_x_max_m": 2.0,
|
||||||
"visible_z_min_m": 0.0,
|
"visible_z_min_m": 0.0,
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "librevna_multi",
|
||||||
|
"serial": "206830985532",
|
||||||
|
"remote_host": "127.0.0.1",
|
||||||
|
"remote_port": 50209,
|
||||||
|
"driver_mode": "native",
|
||||||
|
"mock_signal_hz": 5000000.0,
|
||||||
|
"visa_library": "",
|
||||||
|
"multi_device": {
|
||||||
|
"slave_serials": [
|
||||||
|
"206930965532",
|
||||||
|
"206930A15532"
|
||||||
|
],
|
||||||
|
"force_external_reference": true,
|
||||||
|
"recovery_attempts": 3
|
||||||
|
},
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": "",
|
||||||
|
"executable_path": "",
|
||||||
|
"tty_path": "",
|
||||||
|
"args": [],
|
||||||
|
"env": {},
|
||||||
|
"startup_timeout_s": 5.0,
|
||||||
|
"sweep_timeout_s": 5.0,
|
||||||
|
"stop_timeout_s": 2.0
|
||||||
|
},
|
||||||
|
"laser_control": {
|
||||||
|
"enabled": false,
|
||||||
|
"port": "",
|
||||||
|
"mode": "manual",
|
||||||
|
"pi_coeff1_p": 2560,
|
||||||
|
"pi_coeff1_i": 128,
|
||||||
|
"pi_coeff2_p": 2560,
|
||||||
|
"pi_coeff2_i": 128,
|
||||||
|
"manual": {
|
||||||
|
"temp1": 25.0,
|
||||||
|
"temp2": 25.0,
|
||||||
|
"current1": 30.0,
|
||||||
|
"current2": 30.0
|
||||||
|
},
|
||||||
|
"variation": {
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"static_temp1": 25.0,
|
||||||
|
"static_temp2": 25.0,
|
||||||
|
"static_current1": 30.0,
|
||||||
|
"static_current2": 30.0,
|
||||||
|
"min_value": 30.0,
|
||||||
|
"max_value": 35.0,
|
||||||
|
"step": 0.1,
|
||||||
|
"time_step": 20,
|
||||||
|
"delay_time": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sweep": {
|
||||||
|
"start_hz": 1000000.0,
|
||||||
|
"stop_hz": 6000000000.0,
|
||||||
|
"if_bandwidth_hz": 50000.0,
|
||||||
|
"stimulus_power_dbm": -10.0,
|
||||||
|
"points": 201
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switches": {
|
||||||
|
"port1": {
|
||||||
|
"name": "port1",
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"driver": "h7992",
|
||||||
|
"radar_port": 1,
|
||||||
|
"positions": 2,
|
||||||
|
"default_position": 0,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin_a": 17,
|
||||||
|
"pin_b": 27,
|
||||||
|
"invert_logic": false
|
||||||
|
},
|
||||||
|
"port2": {
|
||||||
|
"name": "port2",
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"driver": "h7992",
|
||||||
|
"radar_port": 2,
|
||||||
|
"positions": 4,
|
||||||
|
"default_position": 0,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin_a": 22,
|
||||||
|
"pin_b": 23,
|
||||||
|
"invert_logic": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"control_button": {
|
||||||
|
"enabled": false,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin": -1,
|
||||||
|
"active_low": true,
|
||||||
|
"bias": "",
|
||||||
|
"debounce_ms": 50,
|
||||||
|
"action": "capture_tmp_reference"
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"settling_ms": 0,
|
||||||
|
"idle_sleep_ms": 2,
|
||||||
|
"continuous": true,
|
||||||
|
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||||
|
"locator_server": {
|
||||||
|
"device_id": 3,
|
||||||
|
"protocol_version": 1,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8888,
|
||||||
|
"max_payload_bytes": 65536,
|
||||||
|
"client_queue_size": 32,
|
||||||
|
"logger_name": "locator_runtime"
|
||||||
|
},
|
||||||
|
"combos": [
|
||||||
|
{
|
||||||
|
"input": 0,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 1,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 0,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 1,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preprocess": {
|
||||||
|
"s21": {
|
||||||
|
"calibration": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s11": {
|
||||||
|
"calibration": {
|
||||||
|
"open": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"short": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"load": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notch": {
|
||||||
|
"enabled": true,
|
||||||
|
"bands_hz": [],
|
||||||
|
"taper_width_hz": 40000000.0,
|
||||||
|
"taper_type": "cosine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gpr": {
|
||||||
|
"relative_permittivity": 1.0,
|
||||||
|
"tx_geometry": [
|
||||||
|
{
|
||||||
|
"output_pos": 0,
|
||||||
|
"x_m": 0.905,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_pos": 1,
|
||||||
|
"x_m": -0.905,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rx_geometry": [
|
||||||
|
{
|
||||||
|
"input_pos": 0,
|
||||||
|
"x_m": -0.18,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 1,
|
||||||
|
"x_m": 0.485,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 2,
|
||||||
|
"x_m": -0.49,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 3,
|
||||||
|
"x_m": 0.185,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rings": {
|
||||||
|
"raw": {
|
||||||
|
"name": "/radar_raw",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"raw_tap": {
|
||||||
|
"name": "/radar_raw_tap",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"preprocessed": {
|
||||||
|
"name": "/radar_preprocessed",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"preprocessed_tap": {
|
||||||
|
"name": "/radar_preprocessed_tap",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"name": "/radar_results",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
"min_depth_m": 2.0,
|
"min_depth_m": 2.0,
|
||||||
"max_depth_m": 14.0,
|
"max_depth_m": 14.0,
|
||||||
"range_comp_power": 0.1,
|
"range_comp_power": 0.1,
|
||||||
"angle_comp_power": 0.0,
|
"object_min_frac": 0.7,
|
||||||
"score_mode": "combined",
|
"score_mode": "combined",
|
||||||
"motion_mode": "int_minus",
|
"motion_mode": "int_minus",
|
||||||
"look_angle_deg": 0.0,
|
"look_angle_deg": 0.0,
|
||||||
@@ -291,6 +291,7 @@
|
|||||||
"ignore_socket_speed_enabled": false,
|
"ignore_socket_speed_enabled": false,
|
||||||
"max_detected_objects_to_draw": 5,
|
"max_detected_objects_to_draw": 5,
|
||||||
"draw_top_m_objects": 2,
|
"draw_top_m_objects": 2,
|
||||||
|
"object_approach_min_frames": 3,
|
||||||
"start_freq_mhz": 3000.0,
|
"start_freq_mhz": 3000.0,
|
||||||
"stop_freq_mhz": 6000.0,
|
"stop_freq_mhz": 6000.0,
|
||||||
"background_subtract_enabled": true,
|
"background_subtract_enabled": true,
|
||||||
@@ -298,7 +299,6 @@
|
|||||||
"remove_sidelobe_objects_enabled": false,
|
"remove_sidelobe_objects_enabled": false,
|
||||||
"imaging_plane_y_m": 0.0,
|
"imaging_plane_y_m": 0.0,
|
||||||
"render_mode": "heatmap",
|
"render_mode": "heatmap",
|
||||||
"min_visible_score": 0.0,
|
|
||||||
"visible_x_min_m": -2.0,
|
"visible_x_min_m": -2.0,
|
||||||
"visible_x_max_m": 2.0,
|
"visible_x_max_m": 2.0,
|
||||||
"visible_z_min_m": 0.0,
|
"visible_z_min_m": 0.0,
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
"min_depth_m": 2.0,
|
"min_depth_m": 2.0,
|
||||||
"max_depth_m": 14.0,
|
"max_depth_m": 14.0,
|
||||||
"range_comp_power": 0.1,
|
"range_comp_power": 0.1,
|
||||||
"angle_comp_power": 0.0,
|
"object_min_frac": 0.7,
|
||||||
"score_mode": "combined",
|
"score_mode": "combined",
|
||||||
"motion_mode": "int_minus",
|
"motion_mode": "int_minus",
|
||||||
"look_angle_deg": 0.0,
|
"look_angle_deg": 0.0,
|
||||||
@@ -291,6 +291,7 @@
|
|||||||
"ignore_socket_speed_enabled": false,
|
"ignore_socket_speed_enabled": false,
|
||||||
"max_detected_objects_to_draw": 5,
|
"max_detected_objects_to_draw": 5,
|
||||||
"draw_top_m_objects": 2,
|
"draw_top_m_objects": 2,
|
||||||
|
"object_approach_min_frames": 3,
|
||||||
"start_freq_mhz": 3000.0,
|
"start_freq_mhz": 3000.0,
|
||||||
"stop_freq_mhz": 6000.0,
|
"stop_freq_mhz": 6000.0,
|
||||||
"background_subtract_enabled": true,
|
"background_subtract_enabled": true,
|
||||||
@@ -298,7 +299,6 @@
|
|||||||
"remove_sidelobe_objects_enabled": false,
|
"remove_sidelobe_objects_enabled": false,
|
||||||
"imaging_plane_y_m": 0.0,
|
"imaging_plane_y_m": 0.0,
|
||||||
"render_mode": "heatmap",
|
"render_mode": "heatmap",
|
||||||
"min_visible_score": 0.0,
|
|
||||||
"visible_x_min_m": -2.0,
|
"visible_x_min_m": -2.0,
|
||||||
"visible_x_max_m": 2.0,
|
"visible_x_max_m": 2.0,
|
||||||
"visible_z_min_m": 0.0,
|
"visible_z_min_m": 0.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user