From 4ca4b27246935a61e54dfca25d37c49e0fcfffa8 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 23 Jun 2026 19:11:22 +0300 Subject: [PATCH 1/5] new libreVNA config --- .gitignore | 1 + .../run_config_librevna_multi.example2.json | 255 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 run_config_examples/run_config_librevna_multi.example2.json diff --git a/.gitignore b/.gitignore index a1b437c..49138fd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build* # C extensions python_app/data* *.so +*.ipynb *.npy snapshots/ test_results* diff --git a/run_config_examples/run_config_librevna_multi.example2.json b/run_config_examples/run_config_librevna_multi.example2.json new file mode 100644 index 0000000..098cc90 --- /dev/null +++ b/run_config_examples/run_config_librevna_multi.example2.json @@ -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 + } + } +} From 42532c98682fd5902360b17d596be9bb86f0f2d5 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 23 Jun 2026 21:55:40 +0300 Subject: [PATCH 2/5] added new filtration and fixed processing parameters --- .../common_cpp/ipc/src/shm_ring.cpp | 6 +- .../include/processing_live_config.hpp | 14 +- .../data_processor/src/data_processor.cpp | 36 ++-- .../src/processing_live_config.cpp | 16 +- .../processors/include/gpr_processor.hpp | 6 + .../include/object_approach_filter.hpp | 172 ++++++++++++++++++ .../src/gpr_backprojection_processor.ipp | 76 ++++++-- .../processors/src/gpr_processor.cpp | 2 +- .../src/sweep_orchestrator.cpp | 4 +- .../live_processing_mixin.py | 11 +- .../app_window_config/profile_io_mixin.py | 8 +- .../app_window_config/state_builders.py | 8 +- .../app_window_plot/gpr_plot_mixin.py | 35 +--- .../sections/processing_section.py | 44 +++-- python_app/models/gui_profile_codec.py | 30 +-- python_app/models/gui_profile_schema.py | 9 +- .../orchestration/live_processing_config.py | 12 +- python_app/tests/test_storage_webui.py | 4 +- run_config.json | 4 +- .../run_config_simulator.example.json | 4 +- run_configs/run_config.json | 4 +- 21 files changed, 365 insertions(+), 140 deletions(-) create mode 100644 data_acq_and_processing/processing/processors/include/object_approach_filter.hpp diff --git a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp index 673eb8b..4d94610 100644 --- a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp +++ b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp @@ -188,7 +188,7 @@ auto ShmRing::open_or_create( const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes; if (magic_ok && version_ok && geometry_ok) { - // Fix #50: the requested mapped_size matched the header geometry, but the + // the requested mapped_size matched the header geometry, but the // backing file may have been created undersized by another process. Confirm // st_size covers the geometry before trusting the mapping. struct stat info {}; @@ -257,7 +257,7 @@ auto ShmRing::open_existing(const std::string& name) -> ShmRing { if (header->version != kRingVersion) { throw std::runtime_error("Shared memory ring version mismatch for " + name); } - // Fix #50: ensure the mapping actually spans every slot the header describes. + // ensure the mapping actually spans every slot the header describes. validate_geometry(*header, mapped_size, name); ShmRing ring{}; @@ -411,7 +411,7 @@ void ShmRing::validate_name(const std::string& name) { } void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) { - // Fix #50: derive the expected size from the header's own geometry fields and + // derive the expected size from the header's own geometry fields and // require the real mapping to cover it. A bogus capacity/slot_size or a truncated // mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV. const std::uint32_t capacity = header.capacity; diff --git a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp index 8f64985..7d9c73e 100644 --- a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp +++ b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp @@ -45,8 +45,10 @@ struct ProcessingLiveConfig { float gpr_min_depth_m = 2.0F; float gpr_max_depth_m = 14.0F; float gpr_range_comp_power = 0.1F; - float gpr_angle_comp_power = 0.0F; float gpr_comp_power = 0.2F; + // BP object-detection stop level, as a fraction of the global peak (Python + // Horns_motion_3libre.py BP_OBJECT_MIN_FRAC); peaks below it are not objects. + float gpr_object_min_frac = 0.7F; std::string gpr_score_mode = "combined"; // Backprojection intra-sweep speed-correction mode: "int_minus" (full // correction) or "int_focus" (focusing residual only). Mirrors the Python @@ -73,13 +75,15 @@ struct ProcessingLiveConfig { // BP image is computed in the y=imaging_plane_y_m slice of the 3D grid. // Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane. float gpr_imaging_plane_y_m = 0.0F; - // Locator filter parameters. Mode-dependent threshold (legacy_gpr uses - // `legacy_gpr_min_visible_pair_count`, everything else uses - // `gpr_min_visible_score`). Draw limits apply only to non-legacy modes. - float gpr_min_visible_score = 0.0F; + // Coherent BP object visibility (window + the draw limits below) is applied in + // the processor itself, matching Horns_motion_3libre.py — there is NO score + // threshold for it. Only legacy GPR still thresholds, on a pair count. float legacy_gpr_min_visible_pair_count = 0.0F; std::uint32_t gpr_max_detected_objects_to_draw = 0; std::uint32_t gpr_draw_top_m_objects = 0; + // Cross-frame approach filter: an object is shown only once it persists as a + // motion-consistent track over this many consecutive frames (<= 1 disables it). + std::uint32_t gpr_object_approach_min_frames = 3; // Visible X/Z window (metres). The locator clips broadcast objects to this // window so the socket emits only what the desktop plot actually shows. float gpr_visible_x_min_m = -2.0F; diff --git a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp index 23ae05f..e111083 100644 --- a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp +++ b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp @@ -65,7 +65,7 @@ void DataProcessor::run(const std::atomic& stop_requested) { std::uint64_t last_applied_history_command_seq = 0; std::uint64_t error_count = 0; std::uint64_t consecutive_errors = 0; - // Fix #55: track the socket-fed speed used for the last reprocess so a change + // track the socket-fed speed used for the last reprocess so a change // arriving without a live-config revision bump still triggers a reprocess of // the current result (gated below by reprocess_current_result). std::optional last_reprocessed_socket_speed = std::nullopt; @@ -124,7 +124,7 @@ void DataProcessor::run(const std::atomic& stop_requested) { publish_locator(replay_result, live_config); } last_replayed_revision = live_revision; - // Fix #55: record the speed we just reprocessed with so an + //record the speed we just reprocessed with so an // unchanged socket value does not retrigger every iteration. last_reprocessed_socket_speed = current_socket_speed; } @@ -232,29 +232,23 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; radar::locator::FilterParams filter{}; - // Clip broadcast objects to the same visible X/Z window the desktop plot uses, - // so the socket emits only the objects the operator actually sees. - filter.visible_bounds = radar::locator::VisibleBounds{ - .x_min = live_config.gpr_visible_x_min_m, - .x_max = live_config.gpr_visible_x_max_m, - .z_min = live_config.gpr_visible_z_min_m, - .z_max = live_config.gpr_visible_z_max_m, - }; if (requested_mode == "legacy_gpr") { + // Legacy GPR emits its objects unfiltered, so the socket applies the legacy + // rule here: clip to the visible window and threshold on the pair count. The + // GUI disables "draw top N" for legacy, so we skip it on the wire to match. + filter.visible_bounds = radar::locator::VisibleBounds{ + .x_min = live_config.gpr_visible_x_min_m, + .x_max = live_config.gpr_visible_x_max_m, + .z_min = live_config.gpr_visible_z_min_m, + .z_max = live_config.gpr_visible_z_max_m, + }; filter.min_score = live_config.legacy_gpr_min_visible_pair_count; - // The GUI deliberately disables the "draw top N" capping for legacy - // GPR, so we also skip it on the wire to match observation semantics. filter.draw_limits.reset(); - } else { - filter.min_score = live_config.gpr_min_visible_score; - if (live_config.gpr_max_detected_objects_to_draw > 0U - && live_config.gpr_draw_top_m_objects > 0U) { - filter.draw_limits = radar::locator::DrawLimits{ - .max_detected_objects = live_config.gpr_max_detected_objects_to_draw, - .draw_top_objects = live_config.gpr_draw_top_m_objects, - }; - } } + // Coherent BP already emits the FINAL visible object set from the processor + // (window + N/M, no score threshold — matching Horns_motion_3libre.py), so the + // socket forwards it verbatim. The default FilterParams passes everything through + // (it only drops non-finite rows), keeping the filtering logic in one place. return filter; } diff --git a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp index b54e582..90d6d5b 100644 --- a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp +++ b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp @@ -255,11 +255,11 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_range_comp_power = static_cast(found->get()); } - if (const auto found = root.find("gpr_angle_comp_power"); found != root.end()) { + if (const auto found = root.find("gpr_object_min_frac"); found != root.end()) { if (!found->is_number()) { - throw std::runtime_error("processing.gpr_angle_comp_power must be number"); + throw std::runtime_error("processing.gpr_object_min_frac must be number"); } - config.gpr_angle_comp_power = static_cast(found->get()); + config.gpr_object_min_frac = static_cast(found->get()); } if (const auto found = root.find("gpr_comp_power"); found != root.end()) { if (!found->is_number()) { @@ -355,12 +355,6 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_imaging_plane_y_m = static_cast(found->get()); } - 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(found->get()); - } for (const auto& [key, target] : { std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m}, std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m}, @@ -388,6 +382,10 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s config.gpr_draw_top_m_objects = parse_u32_number(*found, "processing.gpr_draw_top_m_objects"); } + if (const auto found = root.find("gpr_object_approach_min_frames"); found != root.end()) { + config.gpr_object_approach_min_frames = + parse_u32_number(*found, "processing.gpr_object_approach_min_frames"); + } if (const auto found = root.find("ignore_socket_speed"); found != root.end()) { if (!found->is_boolean()) { throw std::runtime_error("processing.ignore_socket_speed must be bool"); diff --git a/data_acq_and_processing/processing/processors/include/gpr_processor.hpp b/data_acq_and_processing/processing/processors/include/gpr_processor.hpp index dc8a9ed..18567b6 100644 --- a/data_acq_and_processing/processing/processors/include/gpr_processor.hpp +++ b/data_acq_and_processing/processing/processors/include/gpr_processor.hpp @@ -1,5 +1,6 @@ #pragma once +#include "object_approach_filter.hpp" #include "processor_interface.hpp" namespace radar::processing { @@ -13,6 +14,11 @@ class GprProcessor final : public ProcessorInterface { std::span previous_collections, const ProcessingLiveConfig& live_config ) -> ipc::ResultCollection override; + + private: + // Cross-frame "approach" track filter. Persists across collections because the + // owning processor instance is long-lived (one per data_processor run). + ObjectApproachFilter approach_filter_{}; }; class LegacyGprProcessor final : public ProcessorInterface { diff --git a/data_acq_and_processing/processing/processors/include/object_approach_filter.hpp b/data_acq_and_processing/processing/processors/include/object_approach_filter.hpp new file mode 100644 index 0000000..7495def --- /dev/null +++ b/data_acq_and_processing/processing/processors/include/object_approach_filter.hpp @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +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& 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 { + record_frame(Frame{frame_id, frame_time_seconds, objects}); + const std::size_t history_depth = std::max(min_frames, 1U); + while (history_.size() > history_depth) { + history_.pop_front(); + } + + std::vector 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 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& 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::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 history_{}; // recent frames, newest at the back; capped to min_frames +}; + +} // namespace radar::processing diff --git a/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp index 2c98186..868f63f 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp +++ b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp @@ -21,7 +21,6 @@ constexpr double kSmoothSigma = 1.5; // same default 'reflect' (half-sample symmetric) extension — see reflect_index. constexpr double kGaussianTruncate = 4.0; constexpr std::size_t kMaxObjects = 10U; -constexpr double kObjectMinFrac = 0.7; constexpr double kRegionThresholdFrac = 0.75; constexpr double kSuppressThresholdFrac = 0.20; constexpr double kSuppressRadiusXM = 0.80; @@ -1405,7 +1404,8 @@ void apply_depth_gate( [[nodiscard]] auto find_bp_objects( const std::vector& bp_image, - const GridDefinition& grid + const GridDefinition& grid, + double min_frac ) -> std::vector { std::vector objects{}; if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { @@ -1414,7 +1414,7 @@ void apply_depth_gate( std::vector work = bp_image; const double global_peak = max_value(work); - const double stop_level = kObjectMinFrac * global_peak; + const double stop_level = min_frac * global_peak; if (!(global_peak > 0.0)) { return objects; } @@ -1740,7 +1740,15 @@ void add_bp_score_metrics( } } -[[nodiscard]] auto output_objects_sorted( +// Select the FINAL visible objects exactly as Horns_motion_3libre.py does, so the +// processor is the single source of truth: the GUI plot and the locator socket both +// consume this set verbatim (no second, duplicated filter). Steps, in order: +// 1. drop sidelobe candidates (when enabled), then sort by score (peak tie-break); +// 2. clip to the visible X/Z window (display window doubles as an object gate); +// 3. apply the N/M draw rule (BP_MAX_DETECTED_OBJECTS_TO_DRAW / BP_DRAW_TOP_M_OBJECTS): +// if more than N survive, show none; otherwise keep the top M. +// There is deliberately NO score threshold (the Python reference has none). +[[nodiscard]] auto select_visible_objects( const std::vector& objects, const ProcessingLiveConfig& live_config ) -> std::vector { @@ -1750,6 +1758,12 @@ void add_bp_score_metrics( if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) { continue; } + if (object.x_m < live_config.gpr_visible_x_min_m + || object.x_m > live_config.gpr_visible_x_max_m + || object.z_m < live_config.gpr_visible_z_min_m + || object.z_m > live_config.gpr_visible_z_max_m) { + continue; + } visible.push_back(&object); } @@ -1759,6 +1773,17 @@ void add_bp_score_metrics( } return left->selected_score > right->selected_score; }); + + // N/M draw rule (0 on either disables limiting, mirroring the GUI/locator default). + const auto max_detected = live_config.gpr_max_detected_objects_to_draw; + const auto draw_top = live_config.gpr_draw_top_m_objects; + if (max_detected > 0U && draw_top > 0U) { + if (visible.size() > max_detected) { + visible.clear(); + } else if (visible.size() > draw_top) { + visible.resize(draw_top); + } + } return visible; } @@ -1815,7 +1840,8 @@ void add_bp_score_metrics( const config::RunConfig& run_config, const ipc::PreprocessedCollection& collection, std::span previous_collections, - const ProcessingLiveConfig& live_config + const ProcessingLiveConfig& live_config, + ObjectApproachFilter& approach_filter ) -> ipc::ResultCollection { ipc::ResultCollection results{}; results.collection_id = collection.collection_id; @@ -1833,8 +1859,10 @@ void add_bp_score_metrics( return results; } - const double velocity_mps = - kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast(run_config.gpr.relative_permittivity))); + // Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python + // reference Horns_motion_3libre.py (its 0.3 block hardcodes eps_r = 1.0). The + // configurable relative_permittivity stays a legacy-GPR-only knob. + const double velocity_mps = kSpeedOfLightMetersPerSec; const double start_hz = static_cast(live_config.gpr_start_freq_mhz) * 1'000'000.0; const double stop_hz = static_cast(live_config.gpr_stop_freq_mhz) * 1'000'000.0; const double min_depth_m = static_cast(live_config.gpr_min_depth_m); @@ -1920,7 +1948,7 @@ void add_bp_score_metrics( min_depth_m, max_depth_m, std::max(0.0, static_cast(live_config.gpr_range_comp_power)), - std::max(0.0, static_cast(live_config.gpr_angle_comp_power)) + 0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0) ); if (bp.image.empty()) { return results; @@ -1930,7 +1958,7 @@ void add_bp_score_metrics( const auto incoherent_display_map = normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma); - auto objects = find_bp_objects(display_map, grid); + auto objects = find_bp_objects(display_map, grid, static_cast(live_config.gpr_object_min_frac)); add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m); add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor); mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m); @@ -1951,14 +1979,34 @@ void add_bp_score_metrics( results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map)); + // Final visible set (window + N/M), then the cross-frame approach filter: keep only + // objects confirmed as a >= min_frames motion-consistent track (Horns_motion notebook). + const auto visible = select_visible_objects(objects, live_config); + std::vector 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(collection.monotonic_ns) * 1e-9, + static_cast(live_config.gpr_speed_m_s), + static_cast(live_config.gpr_look_angle_deg), + static_cast(live_config.gpr_object_approach_min_frames) + ); + std::vector> point_rows{}; - point_rows.reserve(objects.size()); - for (const auto* object : output_objects_sorted(objects, live_config)) { + point_rows.reserve(visible.size()); + for (std::size_t index = 0U; index < visible.size(); ++index) { + if (!confirmed[index]) { + continue; + } point_rows.push_back( { - static_cast(object->x_m), - static_cast(object->z_m), - static_cast(object->selected_score), + static_cast(visible[index]->x_m), + static_cast(visible[index]->z_m), + static_cast(visible[index]->selected_score), } ); } diff --git a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp index 3374a0c..43172ce 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp @@ -36,7 +36,7 @@ auto GprProcessor::process_collection( std::span previous_collections, const ProcessingLiveConfig& live_config ) -> ipc::ResultCollection { - return process_backprojection_gpr(run_config, collection, previous_collections, live_config); + return process_backprojection_gpr(run_config, collection, previous_collections, live_config, approach_filter_); } auto LegacyGprProcessor::name() const -> std::string { diff --git a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp index d03ff72..8a44b3c 100644 --- a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp +++ b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp @@ -207,7 +207,7 @@ SweepOrchestrator::SweepOrchestrator( void SweepOrchestrator::run(const std::atomic& stop_requested) { // Fail fast on a config error: a slot that is too small for the worst-case payload can // never carry a full collection, so report it clearly at startup instead of dropping - // every collection at runtime (fix #27). + // every collection at runtime. const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points); if (worst_case_bytes > raw_ring_.slot_size_bytes()) { throw std::runtime_error( @@ -219,7 +219,7 @@ void SweepOrchestrator::run(const std::atomic& stop_requested) { } DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_); - // Wait for the devices to become available before starting (fix #8/#9): an absent device + // Wait for the devices to become available before starting: an absent device // makes the orchestrator wait, not exit. if (!lifecycle_guard.open_all_with_retry(stop_requested)) { return; // stop requested before any device became available diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py index 2d4c3b3..3bdafa6 100644 --- a/python_app/gui/controllers/app_window_config/live_processing_mixin.py +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -58,14 +58,14 @@ _WEB_LIVE_SCHEMA = [ ("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")), ("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")), ("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")), - ("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")), ("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")), ("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")), ("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")), ("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")), - ("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")), + ("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")), ("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")), ("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")), + ("gpr_object_approach_min_frames", "Detection", ("gpr",), _attr("_gpr_object_approach_min_frames")), ("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")), ("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")), ("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")), @@ -110,7 +110,9 @@ _WEB_DISPLAY_SCHEMA = [ # take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them # "applies on Start" and editing them just updates the widget for the next start. _WEB_STABLE_SCHEMA = [ - ("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")), + # Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative + # permittivity is a legacy-GPR-only knob; BP ignores it. + ("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")), ("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")), ("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")), ] @@ -475,14 +477,13 @@ class AppWindowLiveProcessingMixin: f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, " f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, " f"range_comp={self._gpr_range_comp_power.value():g}, " - f"angle_comp={self._gpr_angle_comp_power.value():g}, " + f"object_min_frac={self._gpr_object_min_frac.value():g}, " f"score_mode={self._gpr_score_mode.currentText()}, " f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, " f"mean_count={self._gpr_background_mean_count.value()}, " f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, " f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, " f"render_mode={self._gpr_render_mode.currentText()}, " - f"min_score={self._gpr_min_visible_score.value():g}, " f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, " f"draw_top={self._gpr_draw_top_m_objects.value()})" ) diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index bfdd418..ac08b9e 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -291,7 +291,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_min_depth_m, self._gpr_max_depth_m, self._gpr_range_comp_power, - self._gpr_angle_comp_power, + self._gpr_object_min_frac, self._gpr_score_mode, self._gpr_motion_mode, self._gpr_look_angle_deg, @@ -300,6 +300,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_speed_m_s, self._gpr_max_detected_objects_to_draw, self._gpr_draw_top_m_objects, + self._gpr_object_approach_min_frames, self._gpr_start_freq_mhz, self._gpr_stop_freq_mhz, self._gpr_background_subtract_enabled, @@ -307,7 +308,6 @@ class AppWindowConfigProfileIOMixin: self._gpr_remove_sidelobe_objects_enabled, self._gpr_imaging_plane_y_m, self._gpr_render_mode, - self._gpr_min_visible_score, self._gpr_visible_x_min_m, self._gpr_visible_x_max_m, self._gpr_visible_z_min_m, @@ -457,7 +457,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m)) self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m)) self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power)) - self._gpr_angle_comp_power.setValue(float(gui_state.processing.gpr.angle_comp_power)) + self._gpr_object_min_frac.setValue(float(gui_state.processing.gpr.object_min_frac)) self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode) self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode) self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg)) @@ -470,6 +470,7 @@ class AppWindowConfigProfileIOMixin: int(gui_state.processing.gpr.max_detected_objects_to_draw) ) self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects)) + self._gpr_object_approach_min_frames.setValue(int(gui_state.processing.gpr.object_approach_min_frames)) self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz)) self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz)) self._gpr_background_subtract_enabled.setChecked( @@ -481,7 +482,6 @@ class AppWindowConfigProfileIOMixin: ) self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m)) self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode) - self._gpr_min_visible_score.setValue(float(gui_state.processing.gpr.min_visible_score)) self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m)) self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m)) self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m)) diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 8a0776d..08286df 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -224,7 +224,7 @@ class AppWindowConfigStateBuildersMixin: min_depth_m=2.0, max_depth_m=14.0, range_comp_power=0.1, - angle_comp_power=0.0, + object_min_frac=0.7, score_mode="combined", motion_mode="int_minus", look_angle_deg=0.0, @@ -233,6 +233,7 @@ class AppWindowConfigStateBuildersMixin: ignore_socket_speed_enabled=False, max_detected_objects_to_draw=5, draw_top_m_objects=2, + object_approach_min_frames=3, start_freq_mhz=3000.0, stop_freq_mhz=6000.0, background_subtract_enabled=True, @@ -240,7 +241,6 @@ class AppWindowConfigStateBuildersMixin: remove_sidelobe_objects_enabled=True, imaging_plane_y_m=0.0, render_mode="heatmap", - min_visible_score=0.0, visible_x_min_m=default_gpr_x_min_m, visible_x_max_m=default_gpr_x_max_m, visible_z_min_m=0.0, @@ -351,7 +351,7 @@ class AppWindowConfigStateBuildersMixin: min_depth_m=float(self._gpr_min_depth_m.value()), max_depth_m=float(self._gpr_max_depth_m.value()), range_comp_power=float(self._gpr_range_comp_power.value()), - angle_comp_power=float(self._gpr_angle_comp_power.value()), + object_min_frac=float(self._gpr_object_min_frac.value()), score_mode=self._gpr_score_mode.currentText(), motion_mode=self._gpr_motion_mode.currentText(), look_angle_deg=float(self._gpr_look_angle_deg.value()), @@ -360,6 +360,7 @@ class AppWindowConfigStateBuildersMixin: ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()), max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()), draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()), + object_approach_min_frames=int(self._gpr_object_approach_min_frames.value()), start_freq_mhz=float(self._gpr_start_freq_mhz.value()), stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), @@ -367,7 +368,6 @@ class AppWindowConfigStateBuildersMixin: remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()), render_mode=self._gpr_render_mode.currentText(), - min_visible_score=float(self._gpr_min_visible_score.value()), visible_x_min_m=float(self._gpr_visible_x_min_m.value()), visible_x_max_m=float(self._gpr_visible_x_max_m.value()), visible_z_min_m=float(self._gpr_visible_z_min_m.value()), diff --git a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py index 4115c1b..6947feb 100644 --- a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py @@ -8,7 +8,6 @@ import pyqtgraph as pg from python_app.models.dataset_model import ResultCollection from python_app.orchestration.gpr_locator import ( - apply_object_draw_limits as gpr_apply_object_draw_limits, collection_payload_by_name as gpr_collection_payload_by_name, collection_payloads_by_prefix as gpr_collection_payloads_by_prefix, filter_object_rows as gpr_filter_object_rows, @@ -371,26 +370,6 @@ class AppWindowGprPlotMixin: return self._legacy_gpr_render_mode.currentText() return self._gpr_render_mode.currentText() - def _gpr_locator_threshold(self) -> float: - """Return object threshold using the active GPR mode's score semantics.""" - if self._processing_mode.currentText() == "legacy_gpr": - return float(self._legacy_gpr_min_visible_pair_count.value()) - return float(self._gpr_min_visible_score.value()) - - def _gpr_draw_limits(self) -> tuple[int, int] | None: - """Return GPR object draw limits, or None for legacy GPR.""" - if self._processing_mode.currentText() == "legacy_gpr": - return None - return ( - int(self._gpr_max_detected_objects_to_draw.value()), - int(self._gpr_draw_top_m_objects.value()), - ) - - @staticmethod - def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray: - """Apply object count/top-M drawing rules to already-filtered rows.""" - return gpr_apply_object_draw_limits(rows, limits) - @staticmethod def _gpr_display_y_min(z_min: float, z_max: float) -> float: """Return lower display bound, preserving surface markers only when surface is visible.""" @@ -506,18 +485,24 @@ class AppWindowGprPlotMixin: return extract_gpr_object_rows(collection) def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray: - """Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits.""" + """Return the object rows to draw for the active GPR mode. + + Coherent BP is already finalized by the processor (visible window + N/M draw + limits, no score threshold — exactly Horns_motion_3libre.py), so its rows are + drawn verbatim. Legacy GPR is still filtered here by its pair-count threshold + and the visible window. + """ rows = self._gpr_object_rows(collection) - if rows.size == 0: + if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr": return rows x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds() return gpr_filter_object_rows( rows, - min_score=self._gpr_locator_threshold(), + min_score=float(self._legacy_gpr_min_visible_pair_count.value()), x_bounds=(x_min, x_max), z_bounds=(z_min, z_max), - draw_limits=self._gpr_draw_limits(), + draw_limits=None, ) def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool: diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 8e96f34..f231475 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -177,10 +177,14 @@ def build_processing_group(owner) -> QGroupBox: gpr_defaults = owner._defaults_config.gpr + # Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below). + # Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so it is not + # offered there. Applies on the next pipeline start (a run_config field). owner._gpr_relative_permittivity = QDoubleSpinBox() owner._gpr_relative_permittivity.setDecimals(4) owner._gpr_relative_permittivity.setRange(0.0001, 1000.0) owner._gpr_relative_permittivity.setSingleStep(0.05) + owner._gpr_relative_permittivity.setToolTip("Applied on the next pipeline start (Save Config and restart).") owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity)) owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner)) @@ -194,14 +198,13 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_common_page = _build_processing_mode_page( group, [ - ("Relative permittivity", owner._gpr_relative_permittivity), ("Tx geometry", owner._gpr_tx_geometry_input), ("Rx geometry", owner._gpr_rx_geometry_input), ], split_index=1, ) - owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry 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_common_page.layout().addWidget(owner._gpr_geometry_hint) @@ -229,11 +232,15 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_range_comp_power.setSingleStep(0.01) owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power)) - owner._gpr_angle_comp_power = QDoubleSpinBox() - owner._gpr_angle_comp_power.setDecimals(3) - owner._gpr_angle_comp_power.setRange(0.0, 5.0) - owner._gpr_angle_comp_power.setSingleStep(0.01) - owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power)) + owner._gpr_object_min_frac = QDoubleSpinBox() + owner._gpr_object_min_frac.setDecimals(2) + owner._gpr_object_min_frac.setRange(0.0, 1.0) + owner._gpr_object_min_frac.setSingleStep(0.05) + owner._gpr_object_min_frac.setToolTip( + "Object detection stops once a peak falls below this fraction of the global " + "maximum (Horns_motion_3libre.py BP_OBJECT_MIN_FRAC)." + ) + owner._gpr_object_min_frac.setValue(float(gpr_live_defaults.object_min_frac)) owner._gpr_score_mode = QComboBox() owner._gpr_score_mode.addItems(["peak", "combined"]) @@ -289,6 +296,14 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_draw_top_m_objects.setRange(0, 10_000) owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects)) + owner._gpr_object_approach_min_frames = QSpinBox() + owner._gpr_object_approach_min_frames.setRange(1, 100) + owner._gpr_object_approach_min_frames.setToolTip( + "Show an object only after it persists as a motion-consistent track this many " + "consecutive frames (1 disables the approach filter)." + ) + owner._gpr_object_approach_min_frames.setValue(int(gpr_live_defaults.object_approach_min_frames)) + owner._gpr_start_freq_mhz = QDoubleSpinBox() owner._gpr_start_freq_mhz.setDecimals(1) owner._gpr_start_freq_mhz.setRange(100.0, 8800.0) @@ -315,12 +330,6 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_render_mode.addItems(["heatmap", "objects_only"]) owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode) - owner._gpr_min_visible_score = QDoubleSpinBox() - owner._gpr_min_visible_score.setDecimals(2) - owner._gpr_min_visible_score.setRange(0.0, 1.0) - owner._gpr_min_visible_score.setSingleStep(0.05) - owner._gpr_min_visible_score.setValue(float(gpr_live_defaults.min_visible_score)) - owner._gpr_visible_x_min_m = QDoubleSpinBox() owner._gpr_visible_x_min_m.setDecimals(2) owner._gpr_visible_x_min_m.setRange(-100.0, 100.0) @@ -362,7 +371,7 @@ def build_processing_group(owner) -> QGroupBox: ("Min depth m", owner._gpr_min_depth_m), ("Max depth m", owner._gpr_max_depth_m), ("Range comp power", owner._gpr_range_comp_power), - ("Angle comp power", owner._gpr_angle_comp_power), + ("Object min frac", owner._gpr_object_min_frac), ("Score mode", owner._gpr_score_mode), ("Motion mode", owner._gpr_motion_mode), ("Look angle deg", owner._gpr_look_angle_deg), @@ -370,9 +379,9 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_ignore_socket_speed_enabled, ("Speed m/s", owner._gpr_speed_m_s), ("Render mode", owner._gpr_render_mode), - ("Min visible score", owner._gpr_min_visible_score), ("Max detected objects", owner._gpr_max_detected_objects_to_draw), ("Draw top M objects", owner._gpr_draw_top_m_objects), + ("Approach min frames", owner._gpr_object_approach_min_frames), ("Start MHz", owner._gpr_start_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz), ("Imaging plane Y m", owner._gpr_imaging_plane_y_m), @@ -522,6 +531,7 @@ def build_processing_group(owner) -> QGroupBox: owner._processing_mode_pages, [ ("Config mode", owner._legacy_gpr_config_mode), + ("Relative permittivity", owner._gpr_relative_permittivity), ("Input positions", owner._legacy_gpr_input_positions_input), ("Output positions", owner._legacy_gpr_output_positions_input), ("Min depth m", owner._legacy_gpr_min_depth_m), @@ -574,7 +584,7 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed) - owner._gpr_angle_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_object_min_frac.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed) @@ -588,9 +598,9 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed) - owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed) owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed) owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed) + owner._gpr_object_approach_min_frames.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed) owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed) owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index a25368f..0dab47a 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -276,10 +276,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.range_comp_power, "gui.processing.gpr", ), - angle_comp_power=_optional_float( + object_min_frac=_optional_float( gpr_object, - "angle_comp_power", - gui.processing.gpr.angle_comp_power, + "object_min_frac", + gui.processing.gpr.object_min_frac, "gui.processing.gpr", ), score_mode=_optional_string( @@ -330,6 +330,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.draw_top_m_objects, "gui.processing.gpr", ), + object_approach_min_frames=_optional_int( + gpr_object, + "object_approach_min_frames", + gui.processing.gpr.object_approach_min_frames, + "gui.processing.gpr", + ), start_freq_mhz=_optional_float( gpr_object, "start_freq_mhz", @@ -372,12 +378,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.render_mode, "gui.processing.gpr", ), - min_visible_score=_optional_float( - gpr_object, - "min_visible_score", - gui.processing.gpr.min_visible_score, - "gui.processing.gpr", - ), visible_x_min_m=_optional_float( gpr_object, "visible_x_min_m", @@ -467,14 +467,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: ) if gui.processing.gpr.range_comp_power < 0.0: raise ValueError("gui.processing.gpr.range_comp_power must be >= 0") - if gui.processing.gpr.angle_comp_power < 0.0: - raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0") - if gui.processing.gpr.min_visible_score < 0.0: - raise ValueError("gui.processing.gpr.min_visible_score must be >= 0") + if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0: + raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]") if gui.processing.gpr.max_detected_objects_to_draw < 0: raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0") if gui.processing.gpr.draw_top_m_objects < 0: raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0") + if gui.processing.gpr.object_approach_min_frames < 1: + raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1") if gui.processing.legacy_gpr.comp_power < 0.0: raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0") if gui.processing.legacy_gpr.snr_thresh < 0.0: @@ -586,7 +586,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "min_depth_m": gui.processing.gpr.min_depth_m, "max_depth_m": gui.processing.gpr.max_depth_m, "range_comp_power": gui.processing.gpr.range_comp_power, - "angle_comp_power": gui.processing.gpr.angle_comp_power, + "object_min_frac": gui.processing.gpr.object_min_frac, "score_mode": gui.processing.gpr.score_mode, "motion_mode": gui.processing.gpr.motion_mode, "look_angle_deg": gui.processing.gpr.look_angle_deg, @@ -595,6 +595,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled, "max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw, "draw_top_m_objects": gui.processing.gpr.draw_top_m_objects, + "object_approach_min_frames": gui.processing.gpr.object_approach_min_frames, "start_freq_mhz": gui.processing.gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, @@ -602,7 +603,6 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled, "imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m, "render_mode": gui.processing.gpr.render_mode, - "min_visible_score": gui.processing.gpr.min_visible_score, "visible_x_min_m": gui.processing.gpr.visible_x_min_m, "visible_x_max_m": gui.processing.gpr.visible_x_max_m, "visible_z_min_m": gui.processing.gpr.visible_z_min_m, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 185ec09..d55c80d 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -59,7 +59,10 @@ class GuiGprStateModel: min_depth_m: float = 2.0 max_depth_m: float = 14.0 range_comp_power: float = 0.1 - angle_comp_power: float = 0.0 + # BP object-detection stop level, fraction of the global peak (Horns_motion_3libre.py + # BP_OBJECT_MIN_FRAC). Angle compensation and permittivity are fixed for coherent BP + # (Python 0.3 block), so they are not exposed here. + object_min_frac: float = 0.7 score_mode: str = "combined" motion_mode: str = "int_minus" # Intra-sweep motion-correction inputs. Sweep time is derived from acquisition @@ -71,6 +74,9 @@ class GuiGprStateModel: ignore_socket_speed_enabled: bool = False max_detected_objects_to_draw: int = 5 draw_top_m_objects: int = 2 + # Cross-frame approach filter: show an object only after it persists as a + # motion-consistent track this many consecutive frames (<= 1 disables it). + object_approach_min_frames: int = 3 start_freq_mhz: float = 3000.0 stop_freq_mhz: float = 6000.0 background_subtract_enabled: bool = True @@ -78,7 +84,6 @@ class GuiGprStateModel: remove_sidelobe_objects_enabled: bool = True imaging_plane_y_m: float = 0.0 render_mode: str = "heatmap" - min_visible_score: float = 0.0 visible_x_min_m: float = -2.0 visible_x_max_m: float = 2.0 visible_z_min_m: float = 0.0 diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 0ab6a89..c008351 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -32,8 +32,8 @@ class ProcessingLiveConfig: gpr_min_depth_m: float = 2.0 gpr_max_depth_m: float = 14.0 gpr_range_comp_power: float = 0.1 - gpr_angle_comp_power: float = 0.0 gpr_comp_power: float = 0.2 + gpr_object_min_frac: float = 0.7 gpr_score_mode: str = "combined" # Backprojection intra-sweep speed-correction mode: "int_minus" (full # correction) or "int_focus" (focusing residual only). Mirrors Python @@ -41,6 +41,7 @@ class ProcessingLiveConfig: gpr_motion_mode: str = "int_minus" gpr_max_detected_objects_to_draw: int = 5 gpr_draw_top_m_objects: int = 2 + gpr_object_approach_min_frames: int = 3 gpr_speed_m_s: float = 0.0 gpr_look_angle_deg: float = 0.0 # Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips @@ -61,8 +62,9 @@ class ProcessingLiveConfig: gpr_background_mean_count: int = 10 gpr_remove_sidelobe_objects_enabled: bool = True gpr_imaging_plane_y_m: float = 0.0 - # Locator filter parameters consumed by the C++ TCP locator server. - gpr_min_visible_score: float = 0.0 + # Locator filter parameter consumed by the C++ TCP locator server. Coherent BP + # objects are already finalized in the processor (no score threshold); only legacy + # GPR still thresholds, on a pair count. legacy_gpr_min_visible_pair_count: float = 0.0 # Visible X/Z window (metres). The locator and the desktop plot both clip # detected objects to this window, so the socket broadcasts only what is shown. @@ -109,12 +111,13 @@ class ProcessingLiveConfig: "gpr_min_depth_m": float(self.gpr_min_depth_m), "gpr_max_depth_m": float(self.gpr_max_depth_m), "gpr_range_comp_power": float(self.gpr_range_comp_power), - "gpr_angle_comp_power": float(self.gpr_angle_comp_power), "gpr_comp_power": float(self.gpr_comp_power), + "gpr_object_min_frac": float(self.gpr_object_min_frac), "gpr_score_mode": str(self.gpr_score_mode), "gpr_motion_mode": str(self.gpr_motion_mode), "gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw), "gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects), + "gpr_object_approach_min_frames": int(self.gpr_object_approach_min_frames), "gpr_speed_m_s": float(self.gpr_speed_m_s), "gpr_look_angle_deg": float(self.gpr_look_angle_deg), "gpr_direction_sign": float(self.gpr_direction_sign), @@ -128,7 +131,6 @@ class ProcessingLiveConfig: "gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), - "gpr_min_visible_score": float(self.gpr_min_visible_score), "legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count), "gpr_visible_x_min_m": float(self.gpr_visible_x_min_m), "gpr_visible_x_max_m": float(self.gpr_visible_x_max_m), diff --git a/python_app/tests/test_storage_webui.py b/python_app/tests/test_storage_webui.py index bc75c29..9b0e5df 100644 --- a/python_app/tests/test_storage_webui.py +++ b/python_app/tests/test_storage_webui.py @@ -161,8 +161,8 @@ class WebControllerTest(unittest.TestCase): def test_known_field_emits_and_returns_snapshot(self) -> None: received: list[dict] = [] self.controller.apply_settings_requested.connect(received.append) - out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5}) - self.assertEqual(received, [{"gpr_min_visible_score": 0.5}]) + out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5}) + self.assertEqual(received, [{"gpr_object_min_frac": 0.5}]) self.assertIsInstance(out, list) def test_snapshot_is_replaced_and_returned_as_copy(self) -> None: diff --git a/run_config.json b/run_config.json index fa3edaa..364621d 100644 --- a/run_config.json +++ b/run_config.json @@ -282,7 +282,7 @@ "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.1, - "angle_comp_power": 0.0, + "object_min_frac": 0.7, "score_mode": "combined", "motion_mode": "int_minus", "look_angle_deg": 0.0, @@ -291,6 +291,7 @@ "ignore_socket_speed_enabled": false, "max_detected_objects_to_draw": 5, "draw_top_m_objects": 2, + "object_approach_min_frames": 3, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, @@ -298,7 +299,6 @@ "remove_sidelobe_objects_enabled": false, "imaging_plane_y_m": 0.0, "render_mode": "heatmap", - "min_visible_score": 0.0, "visible_x_min_m": -2.0, "visible_x_max_m": 2.0, "visible_z_min_m": 0.0, diff --git a/run_config_examples/run_config_simulator.example.json b/run_config_examples/run_config_simulator.example.json index fa3edaa..364621d 100644 --- a/run_config_examples/run_config_simulator.example.json +++ b/run_config_examples/run_config_simulator.example.json @@ -282,7 +282,7 @@ "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.1, - "angle_comp_power": 0.0, + "object_min_frac": 0.7, "score_mode": "combined", "motion_mode": "int_minus", "look_angle_deg": 0.0, @@ -291,6 +291,7 @@ "ignore_socket_speed_enabled": false, "max_detected_objects_to_draw": 5, "draw_top_m_objects": 2, + "object_approach_min_frames": 3, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, @@ -298,7 +299,6 @@ "remove_sidelobe_objects_enabled": false, "imaging_plane_y_m": 0.0, "render_mode": "heatmap", - "min_visible_score": 0.0, "visible_x_min_m": -2.0, "visible_x_max_m": 2.0, "visible_z_min_m": 0.0, diff --git a/run_configs/run_config.json b/run_configs/run_config.json index fa3edaa..364621d 100644 --- a/run_configs/run_config.json +++ b/run_configs/run_config.json @@ -282,7 +282,7 @@ "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.1, - "angle_comp_power": 0.0, + "object_min_frac": 0.7, "score_mode": "combined", "motion_mode": "int_minus", "look_angle_deg": 0.0, @@ -291,6 +291,7 @@ "ignore_socket_speed_enabled": false, "max_detected_objects_to_draw": 5, "draw_top_m_objects": 2, + "object_approach_min_frames": 3, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, @@ -298,7 +299,6 @@ "remove_sidelobe_objects_enabled": false, "imaging_plane_y_m": 0.0, "render_mode": "heatmap", - "min_visible_score": 0.0, "visible_x_min_m": -2.0, "visible_x_max_m": 2.0, "visible_z_min_m": 0.0, From 3efe968dd1f7e31ca6371245bff5e6594b604702 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Thu, 2 Jul 2026 17:44:24 +0300 Subject: [PATCH 3/5] some kamil_adc fixes --- Makefile | 39 ++++- .../include/switch_sequencer.h | 147 ++++++++++++++++++ .../kamil_adc_collector/src/main.cpp | 124 +++++++++++++++ .../hardware_full/kamil_adc/protocol.py | 31 +++- python_app/hardware_full/kamil_adc/service.py | 58 ++++++- .../hardware_full/kamil_adc/tty_reader.py | 56 ++++++- python_app/scripts/kamil_adc_raw_producer.py | 82 +++++++--- python_app/tests/test_kamil_adc_protocol.py | 39 +++++ python_app/tests/test_kamil_adc_service.py | 100 +++++++++++- .../workflows/sequential_capture_workflow.py | 10 ++ 10 files changed, 644 insertions(+), 42 deletions(-) create mode 100644 data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h diff --git a/Makefile b/Makefile index f071be8..5ff555c 100644 --- a/Makefile +++ b/Makefile @@ -65,17 +65,36 @@ PROCESSOR_SOURCES := \ # from the vendored headers alone and only needs those libraries present at run # time. Built on demand (not part of `all`) for radar.model == kamil_adc. KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector -KAMIL_COLLECTOR_INCLUDES := -I$(KAMIL_COLLECTOR_DIR)/include -I$(KAMIL_COLLECTOR_DIR)/vendor/lcard +# The collector now also drives the RF switches itself (switch-aware mode), so it +# reuses the orchestrator's switch drivers and the shared run_config loader, which +# pull in the common config/ipc/locator headers and the vendored nlohmann/json. +KAMIL_COLLECTOR_INCLUDES := \ + -I$(KAMIL_COLLECTOR_DIR)/include \ + -I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \ + -Idata_acq_and_processing/common_cpp/ipc/include \ + -Idata_acq_and_processing/common_cpp/config/include \ + -Idata_acq_and_processing/sweep_orchestrator/device_drivers/interfaces \ + -Idata_acq_and_processing/sweep_orchestrator/device_drivers/switches \ + -Idata_acq_and_processing/processing/locator/include \ + -Idata_acq_and_processing/third_party KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil +# Collector sources, including the shared drivers/config loader it reuses. These +# are compiled into a private build/kamil/ tree (see rule below) so they never +# collide with the orchestrator's objects, which use different compile flags. KAMIL_COLLECTOR_SOURCES := \ $(KAMIL_COLLECTOR_DIR)/src/main.cpp \ $(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \ - $(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp + $(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \ + data_acq_and_processing/common_cpp/config/src/run_config.cpp \ + data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \ + data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp \ + data_acq_and_processing/sweep_orchestrator/device_drivers/switches/hmc349a_minimal_driver.cpp SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o)) PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o)) DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o)) -KAMIL_COLLECTOR_OBJS := $(addprefix $(BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o)) +KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil +KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(notdir $(KAMIL_COLLECTOR_SOURCES:.cpp=.o))) DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d)) TARGETS := \ @@ -101,11 +120,15 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS) @mkdir -p $(BIN_DIR) $(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS) -# The collector is self-contained: compile its objects with only the vendored -# L-Card headers (no project/VISA includes) by overriding the generic rule's -# variables for these objects, then link with dlopen/openpty support. -$(KAMIL_COLLECTOR_OBJS): INCLUDES := $(KAMIL_COLLECTOR_INCLUDES) -$(KAMIL_COLLECTOR_OBJS): VISA_CXXFLAGS := +# The collector compiles into its own build/kamil/ tree with collector-only +# includes (no VISA), so the shared driver/config sources it reuses never collide +# with the orchestrator's objects of the same name. Basenames are unique, so a +# vpath lets one pattern rule find every source. +vpath %.cpp $(sort $(dir $(KAMIL_COLLECTOR_SOURCES))) + +$(KAMIL_BUILD_DIR)/%.o: %.cpp + @mkdir -p $(dir $@) + $(CXX) $(CXXFLAGS) $(KAMIL_COLLECTOR_INCLUDES) -c $< -o $@ $(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS) @mkdir -p $(BIN_DIR) diff --git a/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h b/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h new file mode 100644 index 0000000..f2ec2b9 --- /dev/null +++ b/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include + +#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 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 input_switch, + std::unique_ptr 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 input_switch_; + std::unique_ptr output_switch_; + std::size_t index_ = 0; + radar::ipc::ComboKey applied_{}; + bool upcoming_dirty_ = false; +}; + +} // namespace radar::kamil diff --git a/data_acq_and_processing/kamil_adc_collector/src/main.cpp b/data_acq_and_processing/kamil_adc_collector/src/main.cpp index 0eb7cb5..ce954e2 100644 --- a/data_acq_and_processing/kamil_adc_collector/src/main.cpp +++ b/data_acq_and_processing/kamil_adc_collector/src/main.cpp @@ -17,6 +17,11 @@ #include "capture_file_writer.h" #include "tty_protocol_writer.h" +#include "switch_sequencer.h" +#include "run_config.hpp" +#include "h7992_minimal_driver.hpp" +#include "hmc349a_minimal_driver.hpp" + #include #include #include @@ -113,6 +118,12 @@ struct Config { std::string live_html_path = "live_plot.html"; std::string live_json_path = "live_plot.json"; std::optional 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 run_config_path; + double switch_gap_ms = 10.0; + double switch_settle_ms = 1.0; bool di1_group_average = false; bool do1_toggle_per_frame = false; bool do1_noise_subtract = false; @@ -479,6 +490,7 @@ void print_help(const char* exe_name) { << " [di1:zero|trace|ignore]\n" << " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n" << " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n" + << " [config:run_config.json] [switch_gap_ms:10] [switch_settle_ms:1]\n" << " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n" << " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n" << " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n" @@ -514,6 +526,13 @@ void print_help(const char* exe_name) { << " step_words:32768 -> input stream transfer step in 32-bit words\n" << " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n" << " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n" + << " config:run_config.json -> switch-aware mode: read the input/output switch and combo\n" + << " configuration from this run_config.json and drive the RF switches\n" + << " in lock-step with sweeps; each sweep is tagged with its combo via a\n" + << " 0x00C0 frame (input_pos, output_pos, dirty). Omit for standalone mode.\n" + << " switch_gap_ms:10 -> guaranteed minimum inter-sweep idle window; lower bound used to decide\n" + << " whether an in-gap switch lands before the next sweep starts\n" + << " switch_settle_ms:1 -> RF settle time required after a switch position change\n" << " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n" << " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n" << " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n" @@ -793,6 +812,18 @@ Config parse_args(int argc, char** argv) { cfg.tty_path = arg.substr(4); continue; } + if (starts_with(arg, "config:")) { + cfg.run_config_path = arg.substr(7); + continue; + } + if (starts_with(arg, "switch_gap_ms:")) { + cfg.switch_gap_ms = parse_double(arg.substr(14), "switch_gap_ms"); + continue; + } + if (starts_with(arg, "switch_settle_ms:")) { + cfg.switch_settle_ms = parse_double(arg.substr(17), "switch_settle_ms"); + continue; + } fail("Unknown argument: " + arg); } @@ -1203,6 +1234,10 @@ constexpr uint32_t kDo1TogglePeriodTicks = 2U; constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U; constexpr uint32_t kDo8HighTicks = 2U; constexpr uint16_t kTtyMarkerDi8High = 0x00A8U; +// Combo tag frame: [0x00C0, input_pos, output_pos, dirty]. Emitted right after a +// sweep-boundary frame to tell the consumer which switch combination the upcoming +// sweep belongs to, and whether it straddled a switch transition (dirty != 0). +constexpr uint16_t kTtyMarkerComboTag = 0x00C0U; constexpr uint32_t kStreamInputAdcFlag = 0x80000000U; constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U; @@ -1802,6 +1837,55 @@ void print_device_info(const t_x502_info& info) { << "MCU firmware: " << info.mcu_firmware_ver << "\n"; } +// Build one switch driver (native GPIO or mock) from a parsed switch config, +// reusing the exact driver implementations the sweep orchestrator uses so both +// acquisition paths drive identical hardware. +std::unique_ptr make_switch_driver(const radar::config::SwitchConfig& sc) { + if (sc.driver_kind == radar::config::SwitchDriverKind::HMC349A) { + return std::make_unique( + 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::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:`. +// Returns nullptr (switching disabled, single-channel passthrough) when no config +// was supplied or it declares no combinations. +std::unique_ptr 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( + std::move(settings), + make_switch_driver(rc.input_switch), + make_switch_driver(rc.output_switch)); +} + int run(const Config& cfg) { Api api; DeviceHandle device(api); @@ -2029,6 +2113,21 @@ int run(const Config& cfg) { tty_writer->emit_packet_start(tty_packet_start_marker); } } + // Switch sequencer (optional): drives the RF switches in lock-step with sweep + // boundaries and tags each sweep with its combination. Disabled (nullptr) when + // no run_config was passed, so the standalone/calibration use stays unchanged. + std::unique_ptr 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 writer; if (!fast_tty_avg_stream_mode) { writer = std::make_unique(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path); @@ -2317,6 +2416,15 @@ int run(const Config& cfg) { auto append_tty_packet_start = [&]() { append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF); + // Right after the boundary, tag the upcoming sweep with its switch combo so + // the consumer can group sweeps and drop the dirty ones. + if (sequencer) { + const radar::ipc::ComboKey combo = sequencer->current_combo(); + append_tty_frame(kTtyMarkerComboTag, + static_cast(combo.input_pos), + static_cast(combo.output_pos), + static_cast(sequencer->current_dirty() ? 1U : 0U)); + } }; auto append_tty_group_step = [&]() { @@ -2714,6 +2822,12 @@ int run(const Config& cfg) { std::cout << "\n"; } + // A real sweep just completed: step the switch sequence into the gap. A + // user-stop teardown is not a sweep boundary, so skip it. + if (sequencer && (frames != 0U) && (reason != PacketCloseReason::UserStop)) { + sequencer->on_sweep_end(current_backlog_ms); + } + packet_active = false; packet_avg_steps = 0; fast_packet_frames = 0; @@ -2768,6 +2882,11 @@ int run(const Config& cfg) { recv_request_words = std::min(ready_words, read_capacity_words); recv_timeout_ms = 0; } + // Backlog already queued in the driver = how far behind real time we are. + // Drives the switch sequencer's in-gap clean/dirty decision. + if ((ready_err == X502_ERR_OK) && (combined_input_rate_hz > 0.0)) { + current_backlog_ms = 1000.0 * static_cast(ready_words) / combined_input_rate_hz; + } const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms); if (recvd < 0) { @@ -3170,6 +3289,11 @@ int run(const Config& cfg) { expect_ok(api, api.StreamsStop(device.hnd), "Stop streams"); device.streams_started = false; + + // Park the switches in their safe default and release the GPIO lines. + if (sequencer) { + sequencer->close(); + } if (cfg.do1_toggle_per_frame) { const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U); expect_ok(api, diff --git a/python_app/hardware_full/kamil_adc/protocol.py b/python_app/hardware_full/kamil_adc/protocol.py index 7415534..c6db4bb 100644 --- a/python_app/hardware_full/kamil_adc/protocol.py +++ b/python_app/hardware_full/kamil_adc/protocol.py @@ -29,6 +29,12 @@ import numpy as np FRAME_BYTES = 8 MAIN_MARKER = 0x000A REFERENCE_MARKER = 0x00A8 +# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware +# collector right after a sweep boundary to label the upcoming sweep with the RF +# switch combination it was captured under (``dirty != 0`` means the sweep straddled +# a switch transition and must be dropped). Absent in the standalone/calibration +# collector, where every sweep carries no combo (``combo is None``). +COMBO_MARKER = 0x00C0 _BOUNDARY_STEP = 0xFFFF # marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q. @@ -52,6 +58,8 @@ class RawSweep: steps: np.ndarray main: np.ndarray reference: np.ndarray + combo: tuple[int, int] | None = None + dirty: bool = False @property def size(self) -> int: @@ -66,13 +74,17 @@ class KamilAdcStreamParser: but holds no I/O and is cheap to unit-test. """ - __slots__ = ("_buffer", "_aligned", "_main", "_reference") + __slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty") def __init__(self) -> None: self._buffer = bytearray() self._aligned = False self._main: dict[int, complex] = {} self._reference: dict[int, complex] = {} + # Combo tag for the sweep currently being accumulated (set by the combo + # frame right after each boundary; ``None`` in non-switch collector modes). + self._pending_combo: tuple[int, int] | None = None + self._pending_dirty = False def feed(self, data: bytes) -> list[RawSweep]: """Append ``data`` and return any sweeps completed by it.""" @@ -95,6 +107,10 @@ class KamilAdcStreamParser: self._main[step] = complex(real, imag) elif marker == REFERENCE_MARKER: self._reference[step] = complex(real, imag) + elif marker == COMBO_MARKER: + # step = input_pos, real = output_pos, imag = dirty flag. + self._pending_combo = (int(step), int(real)) + self._pending_dirty = imag != 0 else: raise ValueError( f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}" @@ -107,6 +123,8 @@ class KamilAdcStreamParser: self._aligned = False self._main.clear() self._reference.clear() + self._pending_combo = None + self._pending_dirty = False def _align(self) -> bool: """Discard pre-roll up to and including the first sweep boundary. @@ -122,6 +140,8 @@ class KamilAdcStreamParser: del self._buffer[: index + FRAME_BYTES] self._main.clear() self._reference.clear() + self._pending_combo = None + self._pending_dirty = False self._aligned = True return True @@ -130,12 +150,21 @@ class KamilAdcStreamParser: shared = sorted(self._main.keys() & self._reference.keys()) main = self._main reference = self._reference + combo = self._pending_combo + dirty = self._pending_dirty self._main = {} self._reference = {} + # The next sweep's combo is set by its own combo frame (right after this + # boundary); clear so a sweep without one reports combo=None rather than + # inheriting a stale tag. + self._pending_combo = None + self._pending_dirty = False if not shared: return None return RawSweep( steps=np.asarray(shared, dtype=np.int32), main=np.asarray([main[step] for step in shared], dtype=np.complex64), reference=np.asarray([reference[step] for step in shared], dtype=np.complex64), + combo=combo, + dirty=dirty, ) diff --git a/python_app/hardware_full/kamil_adc/service.py b/python_app/hardware_full/kamil_adc/service.py index 01804a8..6d1ffd8 100644 --- a/python_app/hardware_full/kamil_adc/service.py +++ b/python_app/hardware_full/kamil_adc/service.py @@ -47,6 +47,10 @@ _REJECT_LOG_EVERY = 50 # brief window to release the device cleanly before escalating to SIGKILL. Caps # the configured stop_timeout_s so a stop can never hang. _STOP_KILL_GRACE_S = 0.5 +# Sweeps to skip after a Python-driven switch change before trusting a capture: one +# for the pre-switch sweep still in the mailbox, one for a possible transition +# straddler in flight. Calibration speed is not critical, so we err on safety. +_SWITCH_DRAIN_SWEEPS = 2 @dataclass(slots=True) @@ -54,6 +58,10 @@ class KamilAdcService: """Launch the external Kamil ADC collector and serve its processed sweeps.""" config: RunConfigModel + # When set, the collector is launched with ``config:`` so it drives the RF + # switches itself (switch-aware mode) from this run_config.json. ``None`` keeps + # the standalone collector that streams a single channel (calibration / mock). + switch_config_path: str | None = None _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False) _reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False) _processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False) @@ -64,9 +72,18 @@ class KamilAdcService: @property def command(self) -> list[str]: - """External collector command, including the generated ``tty:`` argument.""" + """External collector command, including the generated ``tty:`` argument. + + In switch-aware mode (``switch_config_path`` set) the collector also gets + ``config:`` so it reads the switch/combo configuration and drives the + switches in lock-step with the sweeps. + """ adc = self.config.radar.kamil_adc - return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"] + cmd = [str(self._resolve_executable()), *adc.args] + if self.switch_config_path is not None: + cmd.append(f"config:{self.switch_config_path}") + cmd.append(f"tty:{adc.tty_path}") + return cmd def open(self, *, stop_event: threading.Event | None = None) -> None: """Launch the collector and start the TTY reader thread. @@ -122,12 +139,17 @@ class KamilAdcService: """Kamil ADC has no runtime-readable sweep-limit API.""" raise RuntimeError("Kamil ADC device limits are not available") - def acquire(self) -> SweepResult: + def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult: """Return the next sweep that covers the band, as S21 on the fixed grid. Sweeps whose floated frequency range does not span the configured band are rejected and the next sweep is read, until one passes or the sweep timeout elapses (which then surfaces as a :class:`TimeoutError`). + + When ``combo`` is given (switch-aware mode), only the clean sweep captured + under that switch combination is returned; the collector drives the switches + and tags each sweep. When ``None`` (calibration / non-switch mode), the + single newest sweep is returned regardless of combination. """ if self._processor is None: raise RuntimeError("Kamil ADC service is not configured") @@ -147,7 +169,10 @@ class KamilAdcService: raise TimeoutError( "Timed out waiting for a Kamil ADC sweep covering the configured band" ) - raw = self._reader.read_sweep(timeout_s=remaining_s, process=process) + if combo is None: + raw = self._reader.read_sweep(timeout_s=remaining_s, process=process) + else: + raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process) s21 = self._processor.process(raw.main, raw.reference) if s21 is not None: return SweepResult( @@ -159,6 +184,31 @@ class KamilAdcService: ) self._log_rejected_sweep(raw) + def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None: + """Discard sweeps captured before / across a just-applied switch change. + + The collector free-runs, so right after the RF switches move the reader + still holds a sweep captured in the *previous* combination, and a sweep that + straddles the transition may still be in flight. Without this, the next + :meth:`acquire` would return that stale data and the capture would be + attributed to the wrong combination (an off-by-one across the sequence). + + Block until ``sweeps`` freshly-published sweeps have gone by, so the next + :meth:`acquire` returns a sweep captured entirely in the new switch state. + Used by the Python-driven calibration capture, where the collector does not + tag sweeps; the switch-aware collector path handles this with combo tags + instead. No-op when the service is not open. + """ + if self._reader is None: + return + target = self._reader.published_count + max(1, int(sweeps)) + deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s + while self._reader.published_count < target: + if time.monotonic() > deadline: + raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change") + raise_if_process_exited(self._process) + time.sleep(0.005) + def read_raw_sweep(self) -> RawSweep: """Return the next raw (main, reference) sweep without any processing. diff --git a/python_app/hardware_full/kamil_adc/tty_reader.py b/python_app/hardware_full/kamil_adc/tty_reader.py index 9b617da..b88ba9c 100644 --- a/python_app/hardware_full/kamil_adc/tty_reader.py +++ b/python_app/hardware_full/kamil_adc/tty_reader.py @@ -52,6 +52,10 @@ class KamilAdcTtyReader: _stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False) _mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False) _latest_sweep: RawSweep | None = field(init=False, default=None, repr=False) + # Latest clean sweep per switch combination, for the switch-aware collector. + # read_sweep() ignores this and serves the single newest sweep (calibration / + # non-switch mode); read_sweep_for() serves a specific combination. + _combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False) _reader_error: Exception | None = field(init=False, default=None, repr=False) _published_count: int = field(init=False, default=0, repr=False) @@ -62,6 +66,7 @@ class KamilAdcTtyReader: self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK) self._stop_event.clear() self._latest_sweep = None + self._combo_slots = {} self._reader_error = None self._published_count = 0 self._thread = threading.Thread( @@ -89,6 +94,7 @@ class KamilAdcTtyReader: finally: self._fd = None self._latest_sweep = None + self._combo_slots = {} self._reader_error = None @property @@ -131,6 +137,39 @@ class KamilAdcTtyReader: ) self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s)) + def read_sweep_for( + self, + combo: tuple[int, int], + *, + timeout_s: float, + process: subprocess.Popen[bytes] | None = None, + ) -> RawSweep: + """Wait for and return the latest clean sweep for ``combo``. + + Used in switch-aware mode, where the collector drives the switches and tags + each sweep with its combination. Only clean sweeps are delivered (the reader + thread drops the dirty ones); the slot is consumed on read so each caller + gets a fresh capture. Raises like :meth:`read_sweep`. + """ + if self._thread is None: + raise RuntimeError("Kamil ADC TTY reader is not open") + deadline = time.monotonic() + float(timeout_s) + with self._mailbox_cv: + while True: + sweep = self._combo_slots.pop(combo, None) + if sweep is not None: + return sweep + if self._reader_error is not None: + raise self._reader_error + raise_if_process_exited(process) + remaining_s = deadline - time.monotonic() + if remaining_s <= 0.0: + raise TimeoutError( + f"Timed out waiting for Kamil ADC sweep for combo {combo} " + f"after {float(timeout_s):.3f}s" + ) + self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s)) + # ------------------------------------------------------------------ # Reader-thread internals # ------------------------------------------------------------------ @@ -177,11 +216,22 @@ class KamilAdcTtyReader: return chunk def _publish_sweep(self, sweep: RawSweep) -> None: - """Store ``sweep`` as the latest mailbox value, overwriting any unread one.""" + """Publish a completed sweep to the mailbox(es), waking any waiter. + + Dirty sweeps (those that straddled a switch transition) are counted but not + delivered: the collector re-takes that combination on the next sweep. Clean + tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode) + only update the single newest-sweep mailbox that read_sweep() serves. + """ with self._mailbox_cv: - self._latest_sweep = sweep self._published_count += 1 - self._mailbox_cv.notify() + if sweep.dirty: + self._mailbox_cv.notify_all() + return + self._latest_sweep = sweep + if sweep.combo is not None: + self._combo_slots[sweep.combo] = sweep + self._mailbox_cv.notify_all() def _publish_error(self, exc: Exception) -> None: """Record ``exc`` as the reader fault and wake any waiter.""" diff --git a/python_app/scripts/kamil_adc_raw_producer.py b/python_app/scripts/kamil_adc_raw_producer.py index 318dfc0..c4280f9 100644 --- a/python_app/scripts/kamil_adc_raw_producer.py +++ b/python_app/scripts/kamil_adc_raw_producer.py @@ -36,8 +36,8 @@ _OPEN_RETRY_LOG_EVERY = 30 def _open_radar_with_retry( config: RunConfigModel, radar: KamilAdcService, - input_switch: SwitchService, - output_switch: SwitchService, + input_switch: SwitchService | None, + output_switch: SwitchService | None, stop_requested: threading.Event, ) -> bool: """Open+configure the radar and both switches, retrying forever until stop. @@ -48,14 +48,19 @@ def _open_radar_with_retry( relaunched collector starts clean. Returns ``True`` once everything is open, or ``False`` if a stop was requested before the device became available. Backoff is capped and every wait is interruptible by SIGTERM. + + ``input_switch``/``output_switch`` are ``None`` in switch-aware mode, where the + collector owns the GPIO lines and the producer must not open them. """ # Tear down any prior open first: open()/switch.open() are idempotent no-ops # while still "open", so a mid-run reconnect must close them to force a fresh # collector relaunch and TTY re-attach. - with suppress(Exception): - input_switch.close() - with suppress(Exception): - output_switch.close() + if input_switch is not None: + with suppress(Exception): + input_switch.close() + if output_switch is not None: + with suppress(Exception): + output_switch.close() with suppress(Exception): radar.close() @@ -65,15 +70,19 @@ def _open_radar_with_retry( try: radar.open(stop_event=stop_requested) radar.configure(config.radar.sweep) - output_switch.open() - input_switch.open() + if output_switch is not None: + output_switch.open() + if input_switch is not None: + input_switch.open() except Exception as exc: # noqa: BLE001 — waiting for the device is the point # Drop any partial open (collector process, TTY reader, switches) # before the next attempt so the relaunch starts from a clean state. - with suppress(Exception): - input_switch.close() - with suppress(Exception): - output_switch.close() + if input_switch is not None: + with suppress(Exception): + input_switch.close() + if output_switch is not None: + with suppress(Exception): + output_switch.close() with suppress(Exception): radar.close() attempt += 1 @@ -136,9 +145,26 @@ def main() -> int: "Opened SHM ring writers: raw=%s, raw_tap=%s", config.rings.raw.name, config.rings.raw_tap.name, ) - radar = KamilAdcService(config) - input_switch = SwitchService.from_model(config.input_switch) - output_switch = SwitchService.from_model(config.output_switch) + # Switch-aware mode: with native switches the collector drives the RF switches + # itself, in the hardware gap between sweeps, and tags each sweep with its combo + # — no sweep lost at a switch boundary. The producer then only reads tagged + # sweeps and must not touch the GPIO lines the collector owns. With mock switches + # (dev/tests) we keep the Python-driven path, which is fine where speed and the + # in-gap timing do not matter. + collector_driven = ( + config.input_switch.driver_mode == "native" + and config.output_switch.driver_mode == "native" + ) + radar = KamilAdcService( + config, + switch_config_path=str(args.config) if collector_driven else None, + ) + input_switch = None if collector_driven else SwitchService.from_model(config.input_switch) + output_switch = None if collector_driven else SwitchService.from_model(config.output_switch) + logger.info( + "Kamil ADC switch control: %s", + "collector-driven (in-gap, lossless)" if collector_driven else "producer-driven", + ) try: if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested): @@ -155,12 +181,16 @@ def main() -> int: for combo in config.combos: if stop_requested.is_set(): break - output_switch.switch_to(combo.output) - input_switch.switch_to(combo.input) - if config.runtime.settling_ms > 0: - time.sleep(config.runtime.settling_ms / 1000.0) - - sweep = radar.acquire() + if collector_driven: + # The collector already switched and tagged the sweep; just + # read the clean capture for this combination. + sweep = radar.acquire(combo=(combo.input, combo.output)) + else: + output_switch.switch_to(combo.output) + input_switch.switch_to(combo.input) + if config.runtime.settling_ms > 0: + time.sleep(config.runtime.settling_ms / 1000.0) + sweep = radar.acquire() traces.append( TraceData( combo=ComboKey(input=combo.input, output=combo.output), @@ -222,10 +252,12 @@ def main() -> int: logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s) collection_id += 1 finally: - with suppress(Exception): - output_switch.close() - with suppress(Exception): - input_switch.close() + if output_switch is not None: + with suppress(Exception): + output_switch.close() + if input_switch is not None: + with suppress(Exception): + input_switch.close() with suppress(Exception): radar.close() raw_tap_writer.close() diff --git a/python_app/tests/test_kamil_adc_protocol.py b/python_app/tests/test_kamil_adc_protocol.py index 90e0f8b..cdd5eca 100644 --- a/python_app/tests/test_kamil_adc_protocol.py +++ b/python_app/tests/test_kamil_adc_protocol.py @@ -8,6 +8,7 @@ import unittest import numpy as np from python_app.hardware_full.kamil_adc.protocol import ( + COMBO_MARKER, MAIN_MARKER, REFERENCE_MARKER, KamilAdcStreamParser, @@ -18,6 +19,10 @@ def _boundary() -> bytes: return struct.pack(" bytes: + return struct.pack(" bytes: return struct.pack(" None: + parser = KamilAdcStreamParser() + (sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary()) + self.assertIsNone(sweep.combo) + self.assertFalse(sweep.dirty) + + def test_combo_tag_labels_following_sweep(self) -> None: + parser = KamilAdcStreamParser() + stream = ( + _boundary() + _combo(1, 2) + + _main(1, 10, 0) + _reference(1, 100, 0) + + _boundary() + _combo(3, 0, dirty=1) + + _main(1, 20, 0) + _reference(1, 200, 0) + + _boundary() + ) + first, second = parser.feed(stream) + self.assertEqual(first.combo, (1, 2)) + self.assertFalse(first.dirty) + self.assertEqual(second.combo, (3, 0)) + self.assertTrue(second.dirty) + + def test_combo_not_carried_into_untagged_sweep(self) -> None: + parser = KamilAdcStreamParser() + stream = ( + _boundary() + _combo(1, 1) + + _main(1, 1, 0) + _reference(1, 1, 0) + + _boundary() # next sweep has no combo frame + + _main(2, 2, 0) + _reference(2, 2, 0) + + _boundary() + ) + first, second = parser.feed(stream) + self.assertEqual(first.combo, (1, 1)) + self.assertIsNone(second.combo) + def test_dtypes(self) -> None: parser = KamilAdcStreamParser() (sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary()) diff --git a/python_app/tests/test_kamil_adc_service.py b/python_app/tests/test_kamil_adc_service.py index 21666c9..2a17899 100644 --- a/python_app/tests/test_kamil_adc_service.py +++ b/python_app/tests/test_kamil_adc_service.py @@ -16,7 +16,11 @@ import unittest from unittest import mock from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader -from python_app.hardware_full.kamil_adc.protocol import MAIN_MARKER, REFERENCE_MARKER +from python_app.hardware_full.kamil_adc.protocol import ( + COMBO_MARKER, + MAIN_MARKER, + REFERENCE_MARKER, +) from python_app.models.run_config_model import RunConfigModel from python_app.orchestration.process_supervisor import ProcessSupervisor @@ -33,6 +37,10 @@ def _reference(step: int, real: int, imag: int) -> bytes: return struct.pack(" bytes: + return struct.pack(" None: + master_fd, slave_fd, reader = self._open_pty_reader() + try: + os.write( + master_fd, + _boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0) + + _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0) + + _boundary(), + ) + # Each combination is served from its own slot, regardless of order. + second = reader.read_sweep_for((0, 1), timeout_s=1.0) + self.assertEqual(second.main.real.tolist(), [22]) + self.assertEqual(second.combo, (0, 1)) + first = reader.read_sweep_for((0, 0), timeout_s=1.0) + self.assertEqual(first.main.real.tolist(), [11]) + finally: + self._close(master_fd, slave_fd, reader) + + def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None: + master_fd, slave_fd, reader = self._open_pty_reader() + try: + os.write( + master_fd, + # A dirty combo (0,1) sweep, then its clean re-take of the same combo. + _boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0) + + _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0) + + _boundary(), + ) + sweep = reader.read_sweep_for((0, 1), timeout_s=1.0) + # The dirty sweep (99) is dropped; only the clean re-take (42) is served. + self.assertEqual(sweep.main.real.tolist(), [42]) + self.assertFalse(sweep.dirty) + finally: + self._close(master_fd, slave_fd, reader) + class KamilAdcConfigTest(unittest.TestCase): def test_config_round_trip_preserves_kamil_sections(self) -> None: @@ -205,6 +248,61 @@ class KamilAdcConfigTest(unittest.TestCase): with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"): service.close() # must not raise + def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None: + """After a switch change, drain must skip the configured number of freshly + published sweeps before returning, so the next capture is post-switch.""" + import types + + from python_app.hardware_full.kamil_adc import service as service_module + + with tempfile.TemporaryDirectory() as tmp_dir: + config = RunConfigModel.from_dict( + { + "radar": { + "model": "kamil_adc", + "driver_mode": "native", + "kamil_adc": { + "project_dir": tmp_dir, + "executable_path": "/bin/sh", + "tty_path": "/tmp/ttyADC_test", + "sweep_timeout_s": 5.0, + }, + }, + "switches": {"port1": {"positions": 1}, "port2": {"positions": 1}}, + } + ) + service = KamilAdcService(config) + service._reader = types.SimpleNamespace(published_count=10) # type: ignore[assignment] + service._process = types.SimpleNamespace(poll=lambda: None) # type: ignore[assignment] + + # Each poll-sleep advances the published count, as the reader thread would. + def _advance(_seconds: float) -> None: + service._reader.published_count += 1 + + with mock.patch.object(service_module.time, "sleep", _advance): + service.drain_after_switch(sweeps=3) + + # Started at 10, must have waited for at least 3 more sweeps. + self.assertGreaterEqual(service._reader.published_count, 13) + + def test_drain_after_switch_is_noop_when_not_open(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + config = RunConfigModel.from_dict( + { + "radar": { + "model": "kamil_adc", + "driver_mode": "native", + "kamil_adc": { + "project_dir": tmp_dir, + "executable_path": "/bin/sh", + "tty_path": "/tmp/ttyADC_test", + }, + }, + "switches": {"port1": {"positions": 1}, "port2": {"positions": 1}}, + } + ) + KamilAdcService(config).drain_after_switch() # no reader → must not raise + def test_supervisor_selects_kamil_adc_producer(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: config_path = Path(tmp_dir) / "run_config.json" diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 1a1beb5..215f312 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -187,6 +187,16 @@ class SequentialCaptureSession: if self._config.runtime.settling_ms > 0: time.sleep(self._config.runtime.settling_ms / 1000.0) + # A free-running streaming radar (Kamil ADC) keeps a sweep captured in the + # previous combination buffered, and may have a transition-straddling sweep + # in flight. Drop those so this capture holds data from the new switch state + # — otherwise the trace is labelled with this combo but carries the previous + # one's data (an off-by-one across the sequence). Discrete radars (LibreVNA) + # acquire a fresh sweep per call and expose no such method, so skip them. + drain_after_switch = getattr(self._radar, "drain_after_switch", None) + if callable(drain_after_switch): + drain_after_switch() + sweep_traces: list[TraceData] = [] for _ in range(self._median_sweep_count): sweep = self._radar.acquire() From 52c1218bf7b1aeb6de0ce558430aa15814b62e3b Mon Sep 17 00:00:00 2001 From: awe Date: Mon, 27 Jul 2026 16:24:58 +0300 Subject: [PATCH 4/5] Independent monitor + checker for CHANGE_CURRENT_LD1 variation --- python_app/hardware_full/kamil_adc/laser.py | 26 ++ .../laser_control/monitoring/README.md | 247 ++++++++++++++++++ .../laser_control/monitoring/__init__.py | 36 +++ .../laser_control/monitoring/checker.py | 139 ++++++++++ .../laser_control/monitoring/monitor.py | 142 ++++++++++ .../monitoring/readings_channel.py | 131 ++++++++++ .../laser_control/monitoring/session.py | 75 ++++++ python_app/models/run_config_codec.py | 6 + python_app/models/run_config_schema.py | 3 + python_app/scripts/laser_temp_checker.py | 78 ++++++ python_app/scripts/laser_temp_monitor.py | 142 ++++++++++ .../tests/test_laser_temp_monitoring.py | 223 ++++++++++++++++ 12 files changed, 1248 insertions(+) create mode 100644 python_app/hardware_full/laser_control/monitoring/README.md create mode 100644 python_app/hardware_full/laser_control/monitoring/__init__.py create mode 100644 python_app/hardware_full/laser_control/monitoring/checker.py create mode 100644 python_app/hardware_full/laser_control/monitoring/monitor.py create mode 100644 python_app/hardware_full/laser_control/monitoring/readings_channel.py create mode 100644 python_app/hardware_full/laser_control/monitoring/session.py create mode 100644 python_app/scripts/laser_temp_checker.py create mode 100644 python_app/scripts/laser_temp_monitor.py create mode 100644 python_app/tests/test_laser_temp_monitoring.py diff --git a/python_app/hardware_full/kamil_adc/laser.py b/python_app/hardware_full/kamil_adc/laser.py index 3ee22d5..9fcaf17 100644 --- a/python_app/hardware_full/kamil_adc/laser.py +++ b/python_app/hardware_full/kamil_adc/laser.py @@ -86,12 +86,38 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool: "delay_time": variation.delay_time, }, ) + _write_variation_session(variation) return True raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}") finally: controller.disconnect() +def _write_variation_session(variation) -> None: + """Freeze the variation's static temperature targets for the checker. + + Best-effort: a failure to write the session snapshot must never abort the + acquisition setup, so any error is logged and swallowed. + """ + from datetime import datetime + + try: + from python_app.hardware_full.laser_control.monitoring.session import ( + LaserVariationSession, + ) + + LaserVariationSession( + variation_type=variation.variation_type, + target_temp1=variation.static_temp1, + target_temp2=variation.static_temp2, + tolerance_c=variation.temp_tolerance_c, + started_at_iso=datetime.now().isoformat(timespec="seconds"), + ).save() + logger.debug("Wrote laser variation session snapshot for the temperature checker") + except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal + logger.warning("Failed to write laser variation session snapshot", exc_info=True) + + def _validate_laser_control_config(config: RunConfigModel) -> None: laser = config.radar.laser_control if not laser.port: diff --git a/python_app/hardware_full/laser_control/monitoring/README.md b/python_app/hardware_full/laser_control/monitoring/README.md new file mode 100644 index 0000000..38362f9 --- /dev/null +++ b/python_app/hardware_full/laser_control/monitoring/README.md @@ -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:` (фикс. период) | +| `--readings` | `/laser_temp_readings.jsonl` | Куда дописывать показания | +| `--session` | `/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` | `/laser_variation_session.json` | Снимок с целями и допуском | +| `--readings` | `/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:`. (Внутренний счётчик `TO6` платы существует, но его +семантика не гарантирована, поэтому для тайминга он не используется.) + +## Тесты + +```bash +python -m pytest python_app/tests/test_laser_temp_monitoring.py -q +``` + +Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку), +расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому +лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление). diff --git a/python_app/hardware_full/laser_control/monitoring/__init__.py b/python_app/hardware_full/laser_control/monitoring/__init__.py new file mode 100644 index 0000000..e241424 --- /dev/null +++ b/python_app/hardware_full/laser_control/monitoring/__init__.py @@ -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", +] diff --git a/python_app/hardware_full/laser_control/monitoring/checker.py b/python_app/hardware_full/laser_control/monitoring/checker.py new file mode 100644 index 0000000..103624b --- /dev/null +++ b/python_app/hardware_full/laser_control/monitoring/checker.py @@ -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, + ) diff --git a/python_app/hardware_full/laser_control/monitoring/monitor.py b/python_app/hardware_full/laser_control/monitoring/monitor.py new file mode 100644 index 0000000..e44e5df --- /dev/null +++ b/python_app/hardware_full/laser_control/monitoring/monitor.py @@ -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:"`` — a fixed period of ```` 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:'" + ) + + +@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) diff --git a/python_app/hardware_full/laser_control/monitoring/readings_channel.py b/python_app/hardware_full/laser_control/monitoring/readings_channel.py new file mode 100644 index 0000000..26f21cc --- /dev/null +++ b/python_app/hardware_full/laser_control/monitoring/readings_channel.py @@ -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 diff --git a/python_app/hardware_full/laser_control/monitoring/session.py b/python_app/hardware_full/laser_control/monitoring/session.py new file mode 100644 index 0000000..71bf951 --- /dev/null +++ b/python_app/hardware_full/laser_control/monitoring/session.py @@ -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 diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index d9b8cea..2a0ea77 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -331,6 +331,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: model.radar.laser_control.variation.delay_time = _read_int( laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time ) + model.radar.laser_control.variation.temp_tolerance_c = _read_float( + laser_variation_payload, + "temp_tolerance_c", + model.radar.laser_control.variation.temp_tolerance_c, + ) load_switch_payload(port1_payload, model.output_switch) load_switch_payload(port2_payload, model.input_switch) @@ -540,6 +545,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: "step": model.radar.laser_control.variation.step, "time_step": model.radar.laser_control.variation.time_step, "delay_time": model.radar.laser_control.variation.delay_time, + "temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c, }, }, "sweep": sweep_payload, diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 2250d73..13d3dc7 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -116,6 +116,9 @@ class LaserVariationModeModel: step: float = 0.1 time_step: int = 20 delay_time: int = 3 + # Max allowed |measured - target| laser temperature before the temperature + # checker warns, °C. Applied independently to both lasers (temp1/temp2). + temp_tolerance_c: float = 0.03 @dataclass(slots=True) diff --git a/python_app/scripts/laser_temp_checker.py b/python_app/scripts/laser_temp_checker.py new file mode 100644 index 0000000..5151c15 --- /dev/null +++ b/python_app/scripts/laser_temp_checker.py @@ -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()) diff --git a/python_app/scripts/laser_temp_monitor.py b/python_app/scripts/laser_temp_monitor.py new file mode 100644 index 0000000..b8a21e0 --- /dev/null +++ b/python_app/scripts/laser_temp_monitor.py @@ -0,0 +1,142 @@ +"""Standalone laser temperature monitor process. + +Owns the laser serial port, (optionally) starts a current-variation task, then +polls the board once per sweep and appends each reading to a JSONL channel that +the temperature checker tails. Runs until SIGINT/SIGTERM. + +Examples:: + + # Start LD1 current variation from a run config, then monitor: + python -m python_app.scripts.laser_temp_monitor --config run_config.json --start + + # Monitor a variation that is already running: + python -m python_app.scripts.laser_temp_monitor --config run_config.json +""" + +from __future__ import annotations + +import argparse +import logging +import signal +import threading +from datetime import datetime +from pathlib import Path + +from python_app.hardware_full.laser_control.controller import ( + DEVICE_MAIN_MESSAGE_ID, + LaserController, +) +from python_app.hardware_full.laser_control.models import VariationType +from python_app.hardware_full.laser_control.monitoring import ( + DEFAULT_READINGS_PATH, + DEFAULT_SESSION_PATH, + LaserTemperatureMonitor, + LaserVariationSession, + ReadingWriter, + resolve_period_s, +) +from python_app.models.run_config_model import RunConfigModel + +logger = logging.getLogger("laser_temp_monitor") + + +def _start_variation(controller: LaserController, variation) -> None: + """Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot.""" + controller.reset() + controller.set_manual_mode( + temp1=variation.static_temp1, + temp2=variation.static_temp2, + current1=variation.static_current1, + current2=variation.static_current2, + message_id=DEVICE_MAIN_MESSAGE_ID, + ) + controller.start_variation( + variation_type=VariationType[variation.variation_type], + params={ + "static_temp1": variation.static_temp1, + "static_temp2": variation.static_temp2, + "static_current1": variation.static_current1, + "static_current2": variation.static_current2, + "min_value": variation.min_value, + "max_value": variation.max_value, + "step": variation.step, + "time_step": variation.time_step, + "delay_time": variation.delay_time, + }, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep") + parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json") + parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH, + help="JSONL readings channel to append to") + parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH, + help="Session snapshot path (written with --start)") + parser.add_argument("--strategy", default="computed", + help="'computed' (per sweep) or 'interval:'") + parser.add_argument("--start", action="store_true", + help="Send CHANGE_CURRENT_LD1 before monitoring") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + + config = RunConfigModel.load_from_path(args.config) + laser = config.radar.laser_control + variation = laser.variation + if variation.variation_type != "CHANGE_CURRENT_LD1": + logger.warning( + "Only CHANGE_CURRENT_LD1 is supported by firmware; got %s", + variation.variation_type, + ) + + period_s = resolve_period_s( + args.strategy, + min_value=variation.min_value, + max_value=variation.max_value, + step=variation.step, + time_step_us=variation.time_step, + delay_time_ms=variation.delay_time, + ) + + stop_event = threading.Event() + + def request_stop(_signum: int, _frame: object) -> None: + stop_event.set() + + signal.signal(signal.SIGINT, request_stop) + signal.signal(signal.SIGTERM, request_stop) + + controller = LaserController( + port=laser.port or None, + pi_coeff1_p=laser.pi_coeff1_p, + pi_coeff1_i=laser.pi_coeff1_i, + pi_coeff2_p=laser.pi_coeff2_p, + pi_coeff2_i=laser.pi_coeff2_i, + ) + controller.connect() + try: + if args.start: + _start_variation(controller, variation) + LaserVariationSession( + variation_type=variation.variation_type, + target_temp1=variation.static_temp1, + target_temp2=variation.static_temp2, + tolerance_c=variation.temp_tolerance_c, + started_at_iso=datetime.now().isoformat(timespec="seconds"), + ).save(args.session) + logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session) + + with ReadingWriter(args.readings) as writer: + monitor = LaserTemperatureMonitor( + controller=controller, writer=writer, period_s=period_s + ) + logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s) + monitor.run(stop_event) + finally: + controller.disconnect() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python_app/tests/test_laser_temp_monitoring.py b/python_app/tests/test_laser_temp_monitoring.py new file mode 100644 index 0000000..54a2c0e --- /dev/null +++ b/python_app/tests/test_laser_temp_monitoring.py @@ -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() From e219f6ec025e5f18cd939f5ce6720ea94b769770 Mon Sep 17 00:00:00 2001 From: awe Date: Mon, 27 Jul 2026 21:41:14 +0300 Subject: [PATCH 5/5] Add laser temperature monitoring for current-variation runs Independent monitor + checker for CHANGE_CURRENT_LD1 variation, where the board sweeps LD1 current autonomously while both laser temperatures must stay on their static setpoints. Nothing previously verified that the setpoints were actually reached, so a stale temperature silently corrupted measurements. New package python_app/hardware_full/laser_control/monitoring/: - session.py: LaserVariationSession snapshot (targets + tolerance frozen at start) - readings_channel.py: JSONL append/tail channel for per-sweep readings - monitor.py: LaserTemperatureMonitor polls the board once per sweep, publishes - checker.py: LaserTemperatureChecker validates temp1/temp2 vs targets (0.03 C), warns to console per laser with anti-spam state CLI entry points scripts/laser_temp_monitor.py and scripts/laser_temp_checker.py run as independent processes communicating via IPC files. Also: add temp_tolerance_c to LaserVariationModeModel (schema + codec round-trip) and write the session snapshot from apply_kamil_adc_laser_control's variation path so the checker works with pipeline-started runs too. Tests in tests/test_laser_temp_monitoring.py (17 cases); existing laser-control protocol and config-codec suites remain green. Co-Authored-By: Claude Opus 4.8 (1M context) --- python_app/scripts/laser_temp_monitor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/python_app/scripts/laser_temp_monitor.py b/python_app/scripts/laser_temp_monitor.py index b8a21e0..86a8d67 100644 --- a/python_app/scripts/laser_temp_monitor.py +++ b/python_app/scripts/laser_temp_monitor.py @@ -26,6 +26,7 @@ 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, @@ -114,7 +115,13 @@ def main() -> int: pi_coeff2_p=laser.pi_coeff2_p, pi_coeff2_i=laser.pi_coeff2_i, ) - controller.connect() + 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)