diff --git a/data_acq_and_processing/common_cpp/config/src/run_config.cpp b/data_acq_and_processing/common_cpp/config/src/run_config.cpp index e5932e2..c26b0be 100644 --- a/data_acq_and_processing/common_cpp/config/src/run_config.cpp +++ b/data_acq_and_processing/common_cpp/config/src/run_config.cpp @@ -425,8 +425,12 @@ auto load_run_config(const std::string& path) -> RunConfig { static_cast(as_number(required_field(*sweep_obj, "start_hz"), "radar.sweep.start_hz")); config.radar.sweep.stop_hz = static_cast(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz")); - config.radar.sweep.points = - number_to_u32(as_number(required_field(*sweep_obj, "points"), "radar.sweep.points"), "radar.sweep.points"); + if (const auto* points_value = optional_field(*sweep_obj, "points"); points_value != nullptr) { + config.radar.sweep.points = + number_to_u32(as_number(*points_value, "radar.sweep.points"), "radar.sweep.points"); + } else if (config.radar.model != "kamil_adc") { + throw std::runtime_error("Missing required config field: points"); + } config.radar.sweep.if_bandwidth_hz = static_cast( as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz") ); 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 4f12a95..6f795a5 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 @@ -39,6 +39,7 @@ struct ProcessingLiveConfig { float gpr_range_comp_power = 0.28F; float gpr_angle_comp_power = 0.10F; float gpr_comp_power = 0.2F; + std::string gpr_score_mode = "combined"; float gpr_speed_m_s = 0.0F; float gpr_look_angle_deg = 0.0F; float gpr_snr_thresh = 4.5F; @@ -48,6 +49,7 @@ struct ProcessingLiveConfig { bool gpr_background_subtract_enabled = true; std::uint32_t gpr_background_mean_count = 10U; bool gpr_remove_sidelobe_objects_enabled = true; + bool reprocess_current_result = true; std::uint64_t history_command_seq = 0; HistoryCommand history_command = HistoryCommand::None; }; 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 80f2277..7aaaf86 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 @@ -77,7 +77,10 @@ void DataProcessor::run(const std::atomic& stop_requested) { last_applied_history_command_seq = live_config.history_command_seq; } - if (should_replay_entire_history(live_config)) { + if (!live_config.reprocess_current_result) { + // Socket-fed speed updates should affect only future preprocessed collections, + // not replay the current history entry. + } else if (should_replay_entire_history(live_config)) { for (std::size_t index = 0; index < preprocessed_history.size(); ++index) { const auto replay_result = process_collection( preprocessed_history[index], 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 3d0aaf3..09b6c5c 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 @@ -41,6 +41,13 @@ using Json = nlohmann::json; throw std::runtime_error(field_name + " must be one of: point, extended"); } +[[nodiscard]] auto parse_gpr_score_mode(const std::string& value, const std::string& field_name) -> std::string { + if (value == "peak" || value == "combined") { + return value; + } + throw std::runtime_error(field_name + " must be one of: peak, combined"); +} + void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::string& value) { if (value == "backprojection") { return; @@ -241,6 +248,12 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_comp_power = static_cast(found->get()); } + if (const auto found = root.find("gpr_score_mode"); found != root.end()) { + if (!found->is_string()) { + throw std::runtime_error("processing.gpr_score_mode must be string"); + } + config.gpr_score_mode = parse_gpr_score_mode(found->get(), "processing.gpr_score_mode"); + } if (const auto found = root.find("gpr_speed_m_s"); found != root.end()) { if (!found->is_number()) { throw std::runtime_error("processing.gpr_speed_m_s must be number"); @@ -292,6 +305,12 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_remove_sidelobe_objects_enabled = found->get(); } + if (const auto found = root.find("reprocess_current_result"); found != root.end()) { + if (!found->is_boolean()) { + throw std::runtime_error("processing.reprocess_current_result must be bool"); + } + config.reprocess_current_result = found->get(); + } if (const auto found = root.find("history_command_seq"); found != root.end()) { config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq"); } 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 6a2b738..e3c22b3 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 @@ -9,7 +9,11 @@ constexpr std::size_t kAscanOversample = 8U; constexpr double kRangeWeightMax = 5.0; constexpr double kAngleWeightMax = 2.0; constexpr double kTotalWeightMax = 8.0; -constexpr double kCompensationReferenceDepthM = 3.0; +constexpr double kCompensationReferenceDepthM = 5.0; + +constexpr bool kPairNormalize = true; +constexpr double kPairNormPercentile = 50.0; +constexpr double kPairNormEps = 1e-15; constexpr double kSmoothSigma = 1.5; constexpr std::size_t kMaxObjects = 10U; @@ -30,6 +34,18 @@ constexpr double kSidelobeMinDxM = 0.35; constexpr double kSidelobeMaxDzM = 0.70; constexpr double kSidelobeMaxRelativePeak = 0.85; +constexpr double kLocalBgRadiusXM = 1.20; +constexpr double kLocalBgRadiusZM = 0.80; +constexpr double kLocalBgPercentile = 50.0; +constexpr double kLocalContrastEps = 1e-12; + +constexpr double kScoreCohPeakWeight = 0.45; +constexpr double kScoreCoherenceFactorWeight = 0.25; +constexpr double kScoreProminenceWeight = 0.20; +constexpr double kScoreContrastWeight = 0.10; +constexpr double kScoreContrastCap = 6.0; +constexpr double kScoreCfEps = 1e-12; + using PairKey = std::uint64_t; struct GeometrySelection { @@ -72,10 +88,13 @@ struct GridDefinition { struct BpMap { std::vector image{}; std::vector> coherent{}; + std::vector incoherent{}; + std::vector coherence_factor{}; }; struct ObjectRecord { std::size_t index = 0U; + std::size_t peak_index = 0U; double x_peak_m = 0.0; double z_peak_m = 0.0; double x_m = 0.0; @@ -87,6 +106,18 @@ struct ObjectRecord { double center_area_cm2 = 0.0; double mean_value = 0.0; double sum_value = 0.0; + double local_bg = 0.0; + double local_bg_p75 = 0.0; + double prominence = 0.0; + double contrast = 0.0; + double incoh_peak = 0.0; + double incoh_mean = 0.0; + double incoh_center_mean = 0.0; + double coherence_factor_peak = 0.0; + double coherence_factor_center = 0.0; + double score_old = 0.0; + double score_new = 0.0; + double selected_score = 0.0; std::vector region_mask{}; std::vector center_mask{}; bool sidelobe_candidate = false; @@ -178,6 +209,28 @@ void fft_inplace(std::vector>& values, bool inverse) { return median; } +[[nodiscard]] auto percentile_copy(std::vector values, double percentile) -> double { + if (values.empty()) { + return 0.0; + } + + values.erase( + std::remove_if(values.begin(), values.end(), [](double value) { return !std::isfinite(value); }), + values.end() + ); + if (values.empty()) { + return 0.0; + } + + std::sort(values.begin(), values.end()); + const double clamped_percentile = std::clamp(percentile, 0.0, 100.0); + const double position = (clamped_percentile / 100.0) * static_cast(values.size() - 1U); + const auto lower_index = static_cast(std::floor(position)); + const auto upper_index = std::min(lower_index + 1U, values.size() - 1U); + const double fraction = position - static_cast(lower_index); + return (values[lower_index] * (1.0 - fraction)) + (values[upper_index] * fraction); +} + [[nodiscard]] auto build_axis(double min_value, double max_value, std::size_t count) -> std::vector { std::vector axis{}; if (count == 0U) { @@ -576,6 +629,50 @@ void validate_collection_trace_order( return result; } +void normalize_pair_ascans( + std::unordered_map& ascans_by_pair, + double velocity_mps, + double min_depth_m, + double max_depth_m +) { + if (!kPairNormalize) { + return; + } + + for (auto& item : ascans_by_pair) { + auto& ascan = item.second; + if (ascan.samples.empty() || ascan.time_s.size() != ascan.samples.size()) { + continue; + } + + std::vector amplitudes{}; + amplitudes.reserve(ascan.samples.size()); + for (std::size_t index = 0U; index < ascan.samples.size(); ++index) { + const double depth_m = 0.5 * velocity_mps * ascan.time_s[index]; + if (depth_m < min_depth_m || depth_m > max_depth_m) { + continue; + } + amplitudes.push_back(std::abs(ascan.samples[index])); + } + if (amplitudes.empty()) { + amplitudes.reserve(ascan.samples.size()); + for (const auto& sample : ascan.samples) { + amplitudes.push_back(std::abs(sample)); + } + } + + double scale = percentile_copy(std::move(amplitudes), kPairNormPercentile); + if (!std::isfinite(scale) || !(scale > kPairNormEps)) { + scale = 1.0; + } + + const double denominator = scale + kPairNormEps; + for (auto& sample : ascan.samples) { + sample /= denominator; + } + } +} + [[nodiscard]] auto build_grid( const std::vector& x_tx, const std::vector& x_rx, @@ -706,6 +803,8 @@ void validate_collection_trace_order( BpMap result{}; result.image.assign(cell_count, 0.0); result.coherent.assign(cell_count, std::complex(0.0, 0.0)); + result.incoherent.assign(cell_count, 0.0); + result.coherence_factor.assign(cell_count, 0.0); std::vector contribution_count(cell_count, 0.0); for (const auto& trace : selected_traces) { @@ -714,6 +813,10 @@ void validate_collection_trace_order( if (ascan_it == ascans_by_pair.end()) { continue; } + const auto& ascan = ascan_it->second; + if (ascan.time_s.empty()) { + continue; + } const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth( trace.tx_local_index, @@ -736,10 +839,10 @@ void validate_collection_trace_order( const double r_tx = tx_distances[cell_index]; const double r_rx = rx_distances[cell_index]; const double tau_s = (r_tx + r_rx) / velocity_mps; - const auto sample = interpolate_complex(ascan_it->second, tau_s); - if (sample == std::complex(0.0, 0.0)) { + if (tau_s < ascan.time_s.front() || tau_s > ascan.time_s.back()) { continue; } + const auto sample = interpolate_complex(ascan, tau_s); const double weight = compensation_weight( r_tx, @@ -751,6 +854,7 @@ void validate_collection_trace_order( angle_power ); result.coherent[cell_index] += sample * weight; + result.incoherent[cell_index] += std::abs(sample) * weight; contribution_count[cell_index] += 1.0; } } @@ -761,7 +865,10 @@ void validate_collection_trace_order( continue; } result.coherent[index] /= contribution_count[index]; + result.incoherent[index] /= contribution_count[index]; result.image[index] = std::abs(result.coherent[index]); + result.coherence_factor[index] = + std::clamp(result.image[index] / (result.incoherent[index] + kScoreCfEps), 0.0, 1.0); } return result; @@ -786,6 +893,21 @@ void apply_depth_gate( } } +[[nodiscard]] auto normalize_bp_map( + const std::vector& raw_image, + const GridDefinition& grid, + double min_depth_m, + double max_depth_m, + double smooth_sigma +) -> std::vector { + auto normalized = raw_image; + normalize_in_place(normalized); + normalized = gaussian_filter_2d(normalized, grid.x_grid.size(), grid.z_grid.size(), smooth_sigma); + apply_depth_gate(normalized, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m); + normalize_in_place(normalized); + return normalized; +} + [[nodiscard]] auto neighbor_indices( std::size_t index, std::size_t width, @@ -1045,6 +1167,7 @@ void apply_depth_gate( ObjectRecord object{}; object.index = objects.size() + 1U; + object.peak_index = peak_index; object.x_peak_m = x_peak; object.z_peak_m = z_peak; object.x_m = x_center; @@ -1153,6 +1276,187 @@ void mark_sidelobe_candidates( } } +void add_local_prominence_metrics( + std::vector& objects, + const std::vector& bp_image, + const GridDefinition& grid, + double min_depth_m, + double max_depth_m +) { + const std::size_t width = grid.x_grid.size(); + if (bp_image.empty() || width == 0U) { + return; + } + + for (auto& object : objects) { + std::vector bg_values{}; + for (std::size_t index = 0U; index < bp_image.size(); ++index) { + const auto row = index / width; + const auto col = index % width; + const double x_m = grid.x_grid[col]; + const double z_m = grid.z_grid[row]; + const bool in_local_window = + std::abs(x_m - object.x_peak_m) <= kLocalBgRadiusXM && + std::abs(z_m - object.z_peak_m) <= kLocalBgRadiusZM; + const bool in_depth_gate = z_m >= min_depth_m && z_m <= max_depth_m; + if (in_local_window && in_depth_gate && object.region_mask[index] == 0U) { + bg_values.push_back(bp_image[index]); + } + } + + if (bg_values.size() < 10U) { + bg_values.clear(); + for (std::size_t index = 0U; index < bp_image.size(); ++index) { + const auto row = index / width; + const double z_m = grid.z_grid[row]; + if (z_m >= min_depth_m && z_m <= max_depth_m && object.region_mask[index] == 0U) { + bg_values.push_back(bp_image[index]); + } + } + } + + object.local_bg = percentile_copy(bg_values, kLocalBgPercentile); + object.local_bg_p75 = percentile_copy(std::move(bg_values), 75.0); + object.prominence = object.peak - object.local_bg; + object.contrast = object.peak / (object.local_bg + kLocalContrastEps); + } +} + +[[nodiscard]] auto mean_values_under_mask( + const std::vector& values, + const std::vector& mask +) -> double { + if (values.empty() || values.size() != mask.size()) { + return 0.0; + } + + double sum = 0.0; + std::size_t count = 0U; + for (std::size_t index = 0U; index < values.size(); ++index) { + if (mask[index] == 0U) { + continue; + } + sum += values[index]; + count += 1U; + } + return count > 0U ? sum / static_cast(count) : 0.0; +} + +[[nodiscard]] auto max_values_under_mask( + const std::vector& values, + const std::vector& mask +) -> double { + if (values.empty() || values.size() != mask.size()) { + return 0.0; + } + + double maximum = 0.0; + bool has_value = false; + for (std::size_t index = 0U; index < values.size(); ++index) { + if (mask[index] == 0U) { + continue; + } + maximum = has_value ? std::max(maximum, values[index]) : values[index]; + has_value = true; + } + return has_value ? maximum : 0.0; +} + +void add_incoherent_support_metrics( + std::vector& objects, + const std::vector& bp_incoherent_image, + const std::vector& bp_coherence_factor +) { + for (auto& object : objects) { + object.incoh_peak = max_values_under_mask(bp_incoherent_image, object.region_mask); + object.incoh_mean = mean_values_under_mask(bp_incoherent_image, object.region_mask); + object.incoh_center_mean = mean_values_under_mask(bp_incoherent_image, object.center_mask); + if (!(object.incoh_center_mean > 0.0)) { + object.incoh_center_mean = object.incoh_mean; + } + + if (!bp_coherence_factor.empty() && object.peak_index < bp_coherence_factor.size()) { + object.coherence_factor_peak = std::clamp(bp_coherence_factor[object.peak_index], 0.0, 1.0); + } else { + object.coherence_factor_peak = + std::clamp(object.peak / (object.incoh_peak + kScoreCfEps), 0.0, 1.0); + } + + const double center_cf = mean_values_under_mask(bp_coherence_factor, object.center_mask); + object.coherence_factor_center = std::clamp( + center_cf > 0.0 + ? center_cf + : object.mean_value / (object.incoh_center_mean + kScoreCfEps), + 0.0, + 1.0 + ); + } +} + +[[nodiscard]] auto contrast_score_unit(double contrast) -> double { + if (!std::isfinite(contrast) || kScoreContrastCap <= 1.0) { + return 0.0; + } + return std::clamp((contrast - 1.0) / (kScoreContrastCap - 1.0), 0.0, 1.0); +} + +[[nodiscard]] auto use_combined_gpr_score(const ProcessingLiveConfig& live_config) -> bool { + return live_config.gpr_score_mode == "combined"; +} + +void add_bp_score_metrics( + std::vector& objects, + const ProcessingLiveConfig& live_config +) { + double total_weight = + kScoreCohPeakWeight + + kScoreCoherenceFactorWeight + + kScoreProminenceWeight + + kScoreContrastWeight; + if (!(total_weight > 0.0)) { + total_weight = 1.0; + } + + const bool combined_score = use_combined_gpr_score(live_config); + for (auto& object : objects) { + const double coh_peak_score = std::clamp(object.peak, 0.0, 1.0); + const double coherence_factor_score = std::clamp(object.coherence_factor_peak, 0.0, 1.0); + const double prominence_score = std::clamp(object.prominence, 0.0, 1.0); + const double contrast_score = contrast_score_unit(object.contrast); + + object.score_old = coh_peak_score; + object.score_new = ( + (kScoreCohPeakWeight * coh_peak_score) + + (kScoreCoherenceFactorWeight * coherence_factor_score) + + (kScoreProminenceWeight * prominence_score) + + (kScoreContrastWeight * contrast_score) + ) / total_weight; + object.selected_score = combined_score ? object.score_new : object.score_old; + } +} + +[[nodiscard]] auto output_objects_sorted( + const std::vector& objects, + const ProcessingLiveConfig& live_config +) -> std::vector { + std::vector visible{}; + visible.reserve(objects.size()); + for (const auto& object : objects) { + if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) { + continue; + } + visible.push_back(&object); + } + + std::sort(visible.begin(), visible.end(), [](const ObjectRecord* left, const ObjectRecord* right) { + if (left->selected_score == right->selected_score) { + return left->peak > right->peak; + } + return left->selected_score > right->selected_score; + }); + return visible; +} + [[nodiscard]] auto flatten_table( const std::vector>& rows, std::uint32_t column_count @@ -1248,6 +1552,7 @@ void mark_sidelobe_candidates( if (ascans_by_pair.empty()) { return results; } + normalize_pair_ascans(ascans_by_pair, velocity_mps, min_depth_m, max_depth_m); const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kGridZMinM); if (grid.x_grid.empty() || grid.z_grid.empty()) { @@ -1270,13 +1575,15 @@ void mark_sidelobe_candidates( return results; } - normalize_in_place(bp.image); - auto display_map = gaussian_filter_2d(bp.image, grid.x_grid.size(), grid.z_grid.size(), kSmoothSigma); - apply_depth_gate(display_map, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m); - normalize_in_place(display_map); + auto display_map = normalize_bp_map(bp.image, grid, min_depth_m, max_depth_m, kSmoothSigma); + 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); + 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.x_tx, selection.x_rx); + add_bp_score_metrics(objects, live_config); if (live_config.gpr_remove_sidelobe_objects_enabled) { for (const auto& object : objects) { @@ -1295,15 +1602,12 @@ void mark_sidelobe_candidates( std::vector> point_rows{}; point_rows.reserve(objects.size()); - for (const auto& object : objects) { - if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) { - continue; - } + for (const auto* object : output_objects_sorted(objects, live_config)) { point_rows.push_back( { - static_cast(object.x_m), - static_cast(object.z_m), - static_cast(object.peak), + static_cast(object->x_m), + static_cast(object->z_m), + static_cast(object->selected_score), } ); } diff --git a/docs/operation_modes.md b/docs/operation_modes.md index f837736..dbb9b07 100644 --- a/docs/operation_modes.md +++ b/docs/operation_modes.md @@ -266,6 +266,9 @@ Notes: - `executable_path` is mandatory and must name the real Raspberry Pi binary. - The producer appends `tty:` automatically; do not put `tty:` in `radar.kamil_adc.args`. +- The producer derives the sweep point count from the Kamil ADC TTY stream. + `radar.sweep.start_hz` and `stop_hz` define the synthetic frequency axis; + `radar.sweep.points` is not a Kamil ADC setting. - The laser-control driver is vendored under `python_app.hardware_full.laser_control`. - `laser_control` and `kamil_adc` are treated as one hardware configuration. Changing either section requires restarting acquisition so the lasers are diff --git a/docs/run_config.md b/docs/run_config.md index c785ea8..3727f83 100644 --- a/docs/run_config.md +++ b/docs/run_config.md @@ -149,7 +149,9 @@ Fields: The TTY frame format is strict: packet start is `0x000A 0xFFFF 0xFFFF 0xFFFF`, then each sweep point is `0x000A step data1 data2`. Steps must arrive as -`1..points`. `S21` is `data1 + j*data2`; `S11` is stored as explicit zeros. +`1..N`; `N` is derived from the stream when the next packet start arrives. +`radar.sweep.points` is not used by the Kamil ADC producer. `S21` is +`data1 + j*data2`; `S11` is stored as explicit zeros. ### `radar.laser_control` diff --git a/locator_test_client.py b/locator_test_client.py new file mode 100644 index 0000000..ad284c5 --- /dev/null +++ b/locator_test_client.py @@ -0,0 +1,132 @@ +import json +import random +import socket +import struct +import threading +import time +from typing import Any, Dict, Tuple + +HOST = "127.0.0.1" +PORT = 8888 +CLIENT_DEVICE_ID = 0 +MIN_TEST_VLC = 5.0 +MAX_TEST_VLC = 6.0 +SEND_INTERVAL_SECONDS = 1.0 +RECV_TIMEOUT_SECONDS = 1.0 +CONNECT_TIMEOUT_SECONDS = 5.0 +MAX_PAYLOAD_BYTES = 64 * 1024 +HEADER_STRUCT = struct.Struct(" bytes: + """Encode a JSON payload using the protocol binary header.""" + payload_bytes = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + ).encode("utf-8") + return HEADER_STRUCT.pack(device_id, len(payload_bytes)) + payload_bytes + + +def recv_exactly(sock: socket.socket, size: int) -> bytes: + """Receive an exact number of bytes from the socket.""" + chunks = bytearray() + + while len(chunks) < size: + chunk = sock.recv(size - len(chunks)) + if not chunk: + raise ConnectionError("Connection closed by peer.") + chunks.extend(chunk) + + return bytes(chunks) + + +def read_packet(sock: socket.socket) -> Tuple[int, Any]: + """Read and decode a single packet from the server.""" + header_bytes = recv_exactly(sock, HEADER_STRUCT.size) + device_id, payload_length = HEADER_STRUCT.unpack(header_bytes) + + if payload_length > MAX_PAYLOAD_BYTES: + raise ValueError( + "Payload length %d exceeds the %d byte limit." + % (payload_length, MAX_PAYLOAD_BYTES) + ) + + payload_bytes = recv_exactly(sock, payload_length) + payload = json.loads(payload_bytes.decode("utf-8")) + return device_id, payload + +def send_vlc_message(sock: socket.socket, device_id: int, vlc: float) -> None: + """Send one test client payload to the server.""" + payload = {"vlc": vlc} + sock.sendall(encode_packet(payload, device_id=device_id)) + print(f'>>> sent {json.dumps(payload, ensure_ascii=False)} device_id={device_id}') + + +def receive_loop(sock: socket.socket, stop_event: threading.Event) -> None: + """Print all packets received from the locator server.""" + while not stop_event.is_set(): + try: + device_id, payload = read_packet(sock) + except socket.timeout: + continue + except (ConnectionError, OSError, ValueError, json.JSONDecodeError) as error: + if not stop_event.is_set(): + print("Receiver stopped:", error) + stop_event.set() + return + + print(f"<<< received device_id={device_id}") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +def send_loop(sock: socket.socket, stop_event: threading.Event) -> None: + """Send test vlc packets until the client is stopped.""" + send_vlc_message( + sock, + CLIENT_DEVICE_ID, + round(random.uniform(MIN_TEST_VLC, MAX_TEST_VLC), 2), + ) + + if SEND_INTERVAL_SECONDS <= 0: + while not stop_event.is_set(): + time.sleep(0.1) + return + + while not stop_event.is_set(): + time.sleep(SEND_INTERVAL_SECONDS) + send_vlc_message( + sock, + CLIENT_DEVICE_ID, + round(random.uniform(MIN_TEST_VLC, MAX_TEST_VLC), 2), + ) + + +def main() -> None: + """Run the client until interrupted or disconnected.""" + stop_event = threading.Event() + + with socket.create_connection( + (HOST, PORT), + timeout=CONNECT_TIMEOUT_SECONDS, + ) as sock: + sock.settimeout(RECV_TIMEOUT_SECONDS) + print(f"Connected to {HOST}:{PORT}") + + receiver = threading.Thread( + target=receive_loop, + args=(sock, stop_event), + daemon=True, + ) + receiver.start() + + try: + send_loop(sock, stop_event) + except KeyboardInterrupt: + print("Stopping client.") + finally: + stop_event.set() + + +if __name__ == "__main__": + main() diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index d8ddae4..95886a3 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -10,6 +10,7 @@ from collections import deque from datetime import datetime import html import json +import os from pathlib import Path import traceback @@ -217,6 +218,7 @@ class AppWindow( self._on_processing_mode_changed(self._processing_mode.currentText()) self._write_live_processing_config() self._timer.start() + self._maybe_auto_start_pipeline() def _start_locator_service(self) -> None: """Start embedded locator TCP service without failing the GUI.""" @@ -268,6 +270,13 @@ class AppWindow( def _resolve_startup_profile_path(self) -> Path: """Resolve active profile path from session-state or root fallback path.""" + env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip() + if env_profile_path: + profile_path = Path(env_profile_path).expanduser() + if not profile_path.is_absolute(): + profile_path = (self._project_root / profile_path).resolve(strict=False) + return profile_path + try: session_state = self._gui_session_state_store.load() except Exception as exc: @@ -287,6 +296,14 @@ class AppWindow( profile_path = (self._project_root / profile_path).resolve(strict=False) return profile_path + def _maybe_auto_start_pipeline(self) -> None: + """Schedule pipeline start when requested by launcher environment.""" + auto_start = os.environ.get("RADAR_SYSTEM_AUTO_START", "").strip().lower() + if auto_start not in {"1", "true", "yes", "on"}: + return + self._log("Auto-start requested by launcher.") + QTimer.singleShot(500, self._start_run) + def _normalize_profile_path(self, path: Path) -> Path: """Return normalized absolute profile path.""" return path.expanduser().resolve(strict=False) 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 3ecd16c..b046d6a 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 @@ -24,7 +24,12 @@ class AppWindowLiveProcessingMixin: self._live_processing_config(), ) - def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig: + def _live_processing_config( + self, + *, + history_command: str = "none", + reprocess_current_result: bool = True, + ) -> ProcessingLiveConfig: """Build live processing config from current processing widgets.""" self._sync_bscan_frequency_limits_with_radar() self._sync_gpr_frequency_limits_with_radar() @@ -71,6 +76,9 @@ class AppWindowLiveProcessingMixin: gpr_range_comp_power=float(self._gpr_range_comp_power.value()), gpr_angle_comp_power=float(self._gpr_angle_comp_power.value()), gpr_comp_power=float(self._legacy_gpr_comp_power.value()), + gpr_score_mode=self._gpr_score_mode.currentText(), + gpr_max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()), + gpr_draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()), gpr_speed_m_s=float(self._legacy_gpr_speed_m_s.value()), gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()), gpr_snr_thresh=float(self._legacy_gpr_snr_thresh.value()), @@ -80,15 +88,27 @@ class AppWindowLiveProcessingMixin: gpr_background_subtract_enabled=gpr_background_enabled, gpr_background_mean_count=gpr_background_mean_count, gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), + reprocess_current_result=bool(reprocess_current_result), history_command_seq=int(self._history_command_seq), history_command=str(history_command), ) - def _write_live_processing_config(self, *, history_command: str = "none", bump_history_seq: bool = False) -> None: + def _write_live_processing_config( + self, + *, + history_command: str = "none", + bump_history_seq: bool = False, + reprocess_current_result: bool = True, + ) -> None: """Persist current live processing config to runtime JSON file.""" if bump_history_seq: self._history_command_seq += 1 - self._live_config_writer.write(self._live_processing_config(history_command=history_command)) + self._live_config_writer.write( + self._live_processing_config( + history_command=history_command, + reprocess_current_result=reprocess_current_result, + ) + ) def _on_processing_live_settings_changed(self, *_args) -> None: """Handle live-processing setting changes and trigger redraw when needed.""" @@ -215,11 +235,14 @@ class AppWindowLiveProcessingMixin: 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"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"render_mode={self._gpr_render_mode.currentText()}, " - f"min_score={self._gpr_min_visible_score.value():g})" + 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()})" ) elif mode == "legacy_gpr": self._log( 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 f429285..1659be6 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 @@ -70,6 +70,52 @@ class AppWindowConfigProfileIOMixin: self._pass_through_y_min_db.setEnabled(enabled) self._pass_through_y_max_db.setEnabled(enabled) + def _set_radar_settings_mode(self, adc_mode: bool) -> None: + """Show the radar settings panel appropriate for the loaded radar model.""" + self._vna_radar_settings_panel.setVisible(not adc_mode) + self._adc_radar_settings_panel.setVisible(adc_mode) + + def _sync_adc_settings_controls(self) -> None: + """Enable optical-board controls according to enabled flag and mode.""" + if not hasattr(self, "_optical_enabled_checkbox"): + return + enabled = bool(self._optical_enabled_checkbox.isChecked()) + mode = self._optical_mode_combo.currentText() + common_widgets = [ + self._optical_port_input, + self._optical_mode_combo, + self._optical_pi_coeff1_p_input, + self._optical_pi_coeff1_i_input, + self._optical_pi_coeff2_p_input, + self._optical_pi_coeff2_i_input, + ] + manual_widgets = [ + self._optical_manual_temp1_input, + self._optical_manual_temp2_input, + self._optical_manual_current1_input, + self._optical_manual_current2_input, + ] + variation_widgets = [ + self._optical_variation_type_combo, + self._optical_static_temp1_input, + self._optical_static_temp2_input, + self._optical_static_current1_input, + self._optical_static_current2_input, + self._optical_min_value_input, + self._optical_max_value_input, + self._optical_step_input, + self._optical_time_step_input, + self._optical_delay_time_input, + ] + for widget in common_widgets: + widget.setEnabled(enabled) + for widget in manual_widgets: + widget.setEnabled(enabled and mode == "manual") + for widget in variation_widgets: + widget.setEnabled(enabled and mode == "variation") + self._optical_manual_panel.setVisible(mode == "manual") + self._optical_variation_panel.setVisible(mode == "variation") + def _apply_history_limit_from_config(self, config) -> None: """Resize in-memory history buffers to match the loaded config.""" history_limit = self._history_limit_for_config(config) @@ -101,12 +147,17 @@ class AppWindowConfigProfileIOMixin: self._defaults_config = profile.run_config.clone() self._gui_defaults = profile.gui self._remember_active_profile_path(output_path) + points_text = ( + "points=from Kamil ADC stream" + if profile.run_config.is_kamil_adc + else f"points={profile.run_config.radar.sweep.points}" + ) self._log( f"Config profile saved: path={output_path}, " f"combos={len(profile.run_config.combos)}, " f"sweep={profile.run_config.radar.sweep.start_hz:g}.." f"{profile.run_config.radar.sweep.stop_hz:g} Hz, " - f"points={profile.run_config.radar.sweep.points}, " + f"{points_text}, " f"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, " f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, " f"processing_mode={profile.gui.processing.selected_mode}" @@ -203,6 +254,9 @@ class AppWindowConfigProfileIOMixin: self._gpr_max_depth_m, self._gpr_range_comp_power, self._gpr_angle_comp_power, + self._gpr_score_mode, + self._gpr_max_detected_objects_to_draw, + self._gpr_draw_top_m_objects, self._gpr_start_freq_mhz, self._gpr_stop_freq_mhz, self._gpr_background_subtract_enabled, @@ -225,6 +279,7 @@ class AppWindowConfigProfileIOMixin: self._legacy_gpr_start_freq_mhz, self._legacy_gpr_stop_freq_mhz, self._legacy_gpr_speed_m_s, + self._legacy_gpr_ignore_socket_speed_enabled, self._legacy_gpr_look_angle_deg, self._legacy_gpr_background_subtract_enabled, self._legacy_gpr_background_mean_count, @@ -237,6 +292,35 @@ class AppWindowConfigProfileIOMixin: self._save_count, self._save_path_input, self._save_name_input, + self._adc_project_dir_input, + self._adc_executable_path_input, + self._adc_tty_path_input, + self._adc_args_input, + self._adc_env_input, + self._adc_startup_timeout_s_input, + self._adc_sweep_timeout_s_input, + self._adc_stop_timeout_s_input, + self._optical_enabled_checkbox, + self._optical_port_input, + self._optical_mode_combo, + self._optical_pi_coeff1_p_input, + self._optical_pi_coeff1_i_input, + self._optical_pi_coeff2_p_input, + self._optical_pi_coeff2_i_input, + self._optical_manual_temp1_input, + self._optical_manual_temp2_input, + self._optical_manual_current1_input, + self._optical_manual_current2_input, + self._optical_variation_type_combo, + self._optical_static_temp1_input, + self._optical_static_temp2_input, + self._optical_static_current1_input, + self._optical_static_current2_input, + self._optical_min_value_input, + self._optical_max_value_input, + self._optical_step_input, + self._optical_time_step_input, + self._optical_delay_time_input, ) with ExitStack() as blockers: @@ -249,6 +333,42 @@ class AppWindowConfigProfileIOMixin: self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}") self._power_input.setText(f"{config.radar.sweep.power_dbm:g}") self._settling_ms.setText(str(int(config.runtime.settling_ms))) + self._set_radar_settings_mode(config.is_kamil_adc) + + adc = config.radar.kamil_adc + optical = config.radar.laser_control + manual = optical.manual + variation = optical.variation + self._adc_project_dir_input.setText(str(adc.project_dir)) + self._adc_executable_path_input.setText(str(adc.executable_path)) + self._adc_tty_path_input.setText(str(adc.tty_path)) + self._adc_args_input.setPlainText("\n".join(adc.args)) + self._adc_env_input.setPlainText(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}") + self._adc_startup_timeout_s_input.setText(f"{float(adc.startup_timeout_s):g}") + self._adc_sweep_timeout_s_input.setText(f"{float(adc.sweep_timeout_s):g}") + self._adc_stop_timeout_s_input.setText(f"{float(adc.stop_timeout_s):g}") + self._optical_enabled_checkbox.setChecked(bool(optical.enabled)) + self._optical_port_input.setText(str(optical.port)) + self._set_combo_current_text(self._optical_mode_combo, str(optical.mode)) + self._optical_pi_coeff1_p_input.setText(str(int(optical.pi_coeff1_p))) + self._optical_pi_coeff1_i_input.setText(str(int(optical.pi_coeff1_i))) + self._optical_pi_coeff2_p_input.setText(str(int(optical.pi_coeff2_p))) + self._optical_pi_coeff2_i_input.setText(str(int(optical.pi_coeff2_i))) + self._optical_manual_temp1_input.setText(f"{float(manual.temp1):g}") + self._optical_manual_temp2_input.setText(f"{float(manual.temp2):g}") + self._optical_manual_current1_input.setText(f"{float(manual.current1):g}") + self._optical_manual_current2_input.setText(f"{float(manual.current2):g}") + self._set_combo_current_text(self._optical_variation_type_combo, str(variation.variation_type)) + self._optical_static_temp1_input.setText(f"{float(variation.static_temp1):g}") + self._optical_static_temp2_input.setText(f"{float(variation.static_temp2):g}") + self._optical_static_current1_input.setText(f"{float(variation.static_current1):g}") + self._optical_static_current2_input.setText(f"{float(variation.static_current2):g}") + self._optical_min_value_input.setText(f"{float(variation.min_value):g}") + self._optical_max_value_input.setText(f"{float(variation.max_value):g}") + self._optical_step_input.setText(f"{float(variation.step):g}") + self._optical_time_step_input.setText(str(int(variation.time_step))) + self._optical_delay_time_input.setText(str(int(variation.delay_time))) + self._sync_adc_settings_controls() self._combos_text.setText(str(gui_state.switches.combos_text)) self._single_combo_output.setText(str(gui_state.switches.single_output)) @@ -289,6 +409,11 @@ class AppWindowConfigProfileIOMixin: 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._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode) + self._gpr_max_detected_objects_to_draw.setValue( + 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_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( @@ -316,6 +441,9 @@ class AppWindowConfigProfileIOMixin: self._legacy_gpr_start_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.start_freq_mhz)) self._legacy_gpr_stop_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.stop_freq_mhz)) self._legacy_gpr_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s)) + self._legacy_gpr_ignore_socket_speed_enabled.setChecked( + bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled) + ) self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg)) self._legacy_gpr_background_subtract_enabled.setChecked( bool(gui_state.processing.legacy_gpr.background_subtract_enabled) 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 dc91744..19fdb7a 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + from python_app.models.gui_profile_model import ( GuiBscanStateModel, GuiDataActionsStateModel, @@ -42,6 +44,27 @@ class AppWindowConfigStateBuildersMixin: values.append(int(token)) return values + @staticmethod + def _parse_multiline_string_list(text: str) -> list[str]: + """Parse one command argument per non-empty line.""" + return [line.strip() for line in text.splitlines() if line.strip()] + + @staticmethod + def _parse_string_env_json(text: str) -> dict[str, str]: + """Parse strict string-to-string environment JSON.""" + cleaned = text.strip() + if not cleaned: + return {} + payload = json.loads(cleaned) + if not isinstance(payload, dict): + raise ValueError("Environment JSON must be an object") + env: dict[str, str] = {} + for key, value in payload.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError("Environment JSON must contain only string keys and values") + env[key] = value + return env + @staticmethod def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]: """Parse line-based Tx geometry editor text.""" @@ -172,6 +195,9 @@ class AppWindowConfigStateBuildersMixin: max_depth_m=14.0, range_comp_power=0.28, angle_comp_power=0.10, + score_mode="combined", + max_detected_objects_to_draw=5, + draw_top_m_objects=2, start_freq_mhz=3000.0, stop_freq_mhz=6000.0, background_subtract_enabled=True, @@ -193,6 +219,7 @@ class AppWindowConfigStateBuildersMixin: start_freq_mhz=3000.0, stop_freq_mhz=6000.0, speed_m_s=0.0, + ignore_socket_speed_enabled=False, look_angle_deg=0.0, snr_thresh=4.5, snr_comp_max=25.0, @@ -280,6 +307,9 @@ class AppWindowConfigStateBuildersMixin: 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()), + score_mode=self._gpr_score_mode.currentText(), + 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()), 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()), @@ -302,6 +332,7 @@ class AppWindowConfigStateBuildersMixin: start_freq_mhz=float(self._legacy_gpr_start_freq_mhz.value()), stop_freq_mhz=float(self._legacy_gpr_stop_freq_mhz.value()), speed_m_s=float(self._legacy_gpr_speed_m_s.value()), + ignore_socket_speed_enabled=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()), look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()), snr_thresh=float(self._legacy_gpr_snr_thresh.value()), snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()), @@ -343,6 +374,8 @@ class AppWindowConfigStateBuildersMixin: config.radar.sweep.points = int(self._points_input.text().strip()) config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip()) config.radar.sweep.power_dbm = float(self._power_input.text().strip()) + if config.is_kamil_adc: + self._apply_adc_settings_to_config(config) config.runtime.settling_ms = int(self._settling_ms.text().strip()) config.runtime.processing_live_config_path = str(self._live_config_writer.path) @@ -396,6 +429,16 @@ class AppWindowConfigStateBuildersMixin: def _radar_key_from_ui(self) -> str: """Build current radar key directly from radar widgets only.""" model_name = self._defaults_config.radar.model or RunConfigModel.LIBREVNA_MODEL + extra_parts = self._defaults_config.radar_key_extra_parts() + if self._defaults_config.is_kamil_adc: + config = self._defaults_config.clone() + config.radar.sweep.start_hz = float(self._start_hz_input.text().strip()) + config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip()) + config.radar.sweep.points = int(self._points_input.text().strip()) + config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip()) + config.radar.sweep.power_dbm = float(self._power_input.text().strip()) + self._apply_adc_settings_to_config(config) + extra_parts = config.radar_key_extra_parts() return radar_key_from_config( model_name=model_name, serial=self._defaults_config.radar.serial, @@ -404,9 +447,46 @@ class AppWindowConfigStateBuildersMixin: sweep_points=int(self._points_input.text().strip()), ifbw_hz=float(self._ifbw_input.text().strip()), power_dbm=float(self._power_input.text().strip()), - extra_serials=self._defaults_config.radar_key_extra_parts() or None, + extra_serials=extra_parts or None, ) + def _apply_adc_settings_to_config(self, config: RunConfigModel) -> None: + """Copy visible ADC collector and optical-board settings into config.""" + adc = config.radar.kamil_adc + adc.project_dir = self._adc_project_dir_input.text().strip() + adc.executable_path = self._adc_executable_path_input.text().strip() + adc.tty_path = self._adc_tty_path_input.text().strip() + adc.args = self._parse_multiline_string_list(self._adc_args_input.toPlainText()) + adc.env = self._parse_string_env_json(self._adc_env_input.toPlainText()) + adc.startup_timeout_s = float(self._adc_startup_timeout_s_input.text().strip()) + adc.sweep_timeout_s = float(self._adc_sweep_timeout_s_input.text().strip()) + adc.stop_timeout_s = float(self._adc_stop_timeout_s_input.text().strip()) + + optical = config.radar.laser_control + optical.enabled = bool(self._optical_enabled_checkbox.isChecked()) + optical.port = self._optical_port_input.text().strip() + optical.mode = self._optical_mode_combo.currentText() + optical.pi_coeff1_p = int(self._optical_pi_coeff1_p_input.text().strip()) + optical.pi_coeff1_i = int(self._optical_pi_coeff1_i_input.text().strip()) + optical.pi_coeff2_p = int(self._optical_pi_coeff2_p_input.text().strip()) + optical.pi_coeff2_i = int(self._optical_pi_coeff2_i_input.text().strip()) + + optical.manual.temp1 = float(self._optical_manual_temp1_input.text().strip()) + optical.manual.temp2 = float(self._optical_manual_temp2_input.text().strip()) + optical.manual.current1 = float(self._optical_manual_current1_input.text().strip()) + optical.manual.current2 = float(self._optical_manual_current2_input.text().strip()) + + optical.variation.variation_type = self._optical_variation_type_combo.currentText() + optical.variation.static_temp1 = float(self._optical_static_temp1_input.text().strip()) + optical.variation.static_temp2 = float(self._optical_static_temp2_input.text().strip()) + optical.variation.static_current1 = float(self._optical_static_current1_input.text().strip()) + optical.variation.static_current2 = float(self._optical_static_current2_input.text().strip()) + optical.variation.min_value = float(self._optical_min_value_input.text().strip()) + optical.variation.max_value = float(self._optical_max_value_input.text().strip()) + optical.variation.step = float(self._optical_step_input.text().strip()) + optical.variation.time_step = int(self._optical_time_step_input.text().strip()) + optical.variation.delay_time = int(self._optical_delay_time_input.text().strip()) + @staticmethod def _switches_are_effectively_static(config: RunConfigModel) -> bool: """Return `True` when non-GPR switch setup effectively yields one fixed combo.""" diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 14eff84..307b960 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import QSignalBlocker from python_app.gui.runtime.constraints import validate_processing_mode_constraints from python_app.gui.runtime.history import build_run_history_signature, record_result_history +from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control from python_app.hardware_full.single_radar_service import create_single_radar_service from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection from python_app.models.run_config_model import RunConfigModel @@ -86,7 +87,7 @@ class AppWindowPipelineMixin: self._config_writer.prepare_preprocess_bundles(self._store, radar_key, config) config.runtime.continuous = not single_capture - if not single_capture: + if not single_capture and not config.is_kamil_adc: self._prepare_radar_for_native_acquisition(config) config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json") @@ -168,11 +169,16 @@ class AppWindowPipelineMixin: "matches the current config" ) self._prepare_radar_for_native_acquisition(config) + points_text = ( + "points=from Kamil ADC stream" + if config.is_kamil_adc + else f"points={config.radar.sweep.points}" + ) self._log( "Radar settings applied: " f"start={config.radar.sweep.start_hz:g} Hz, " f"stop={config.radar.sweep.stop_hz:g} Hz, " - f"points={config.radar.sweep.points}, " + f"{points_text}, " f"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, " f"power={config.radar.sweep.power_dbm:g} dBm" ) @@ -193,7 +199,10 @@ class AppWindowPipelineMixin: self._log("Multi-device raw producer will configure all LibreVNA devices") return if config.is_kamil_adc: - self._log("Kamil ADC raw producer will apply laser_control and start the external collector") + if apply_kamil_adc_laser_control(config): + self._log("Kamil ADC laser_control applied via Apply Radar") + else: + self._log("Kamil ADC laser_control skipped because it is disabled") return radar_service = create_single_radar_service(config) @@ -272,6 +281,7 @@ class AppWindowPipelineMixin: try: self._drain_locator_speed_updates() + self._drain_locator_log_updates() if self._raw_reader is not None: self._read_all_raw() self._read_all_preprocessed() @@ -483,6 +493,8 @@ class AppWindowPipelineMixin: return if self._processing_mode.currentText() != "legacy_gpr": return + if self._legacy_gpr_ignore_socket_speed_enabled.isChecked(): + return previous_speed_m_s = float(self._legacy_gpr_speed_m_s.value()) with QSignalBlocker(self._legacy_gpr_speed_m_s): @@ -491,7 +503,14 @@ class AppWindowPipelineMixin: if current_speed_m_s == previous_speed_m_s: return - self._write_live_processing_config() + self._write_live_processing_config(reprocess_current_result=False) + + def _drain_locator_log_updates(self) -> None: + """Append queued locator socket traffic messages to the runtime log.""" + if self._locator_service is None: + return + for message in self._locator_service.drain_log_updates(): + self._log(message) def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None: """Publish one locator snapshot from a GPR result collection.""" @@ -501,6 +520,7 @@ class AppWindowPipelineMixin: collection, self._gpr_locator_threshold(), visible_bounds=self._gpr_visible_object_bounds(), + object_draw_limits=self._gpr_draw_limits(), ) def _publish_locator_snapshot_from_latest_result(self) -> None: 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 75e1d81..2c448f8 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 @@ -254,7 +254,15 @@ class AppWindowGprPlotMixin: points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4) if points_payload is not None and np.asarray(points_payload.table).size > 0: - points = np.asarray(points_payload.table, dtype=np.float32) + points = ( + self._filtered_gpr_object_rows(collection) + if self._processing_mode.currentText() == "gpr" + else np.asarray(points_payload.table, dtype=np.float32) + ) + else: + points = np.zeros((0, 3), dtype=np.float32) + + if points.size > 0: self._gpr_points_item.setData( x=points[:, 0], y=points[:, 1], @@ -368,6 +376,26 @@ class AppWindowGprPlotMixin: 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.""" + if limits is None or rows.size == 0: + return rows + + max_detected_objects, draw_top_objects = limits + if rows.shape[0] > int(max_detected_objects): + return np.zeros((0, rows.shape[1]), dtype=rows.dtype) + return rows[: max(0, int(draw_top_objects))] + @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.""" @@ -483,7 +511,7 @@ class AppWindowGprPlotMixin: return extract_gpr_object_rows(collection) def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray: - """Return object rows filtered by minimum pair count and visible X/Z bounds.""" + """Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits.""" rows = self._gpr_object_rows(collection) if rows.size == 0: return rows @@ -499,7 +527,7 @@ class AppWindowGprPlotMixin: & (rows[:, 1] >= z_min) & (rows[:, 1] <= z_max) ) - return rows[visible_mask] + return self._apply_object_draw_limits(rows[visible_mask], self._gpr_draw_limits()) def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool: """Draw only detected GPR objects inside configured X/Z bounds.""" diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 765511f..1e78a1d 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -3,12 +3,14 @@ from __future__ import annotations from python_app.gui.preprocess_dialog import PreprocessDialog +from python_app.hardware_full.kamil_adc_service import KamilAdcService from python_app.orchestration.preprocess_assets import ( PREPROCESS_ASSET_SPECS, VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_channel, preprocess_asset_display_name, ) +from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets from python_app.workflows.multi_radar_capture_workflow import ( MultiRadarCaptureBatch, MultiRadarSequentialCaptureSession, @@ -185,6 +187,8 @@ class AppWindowPreprocessMixin: dialog.undo_last_requested.connect(self._undo_last_capture) dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence) dialog.abort_sequence_requested.connect(self._abort_capture_sequence) + dialog.create_kamil_adc_neutral_sets_requested.connect(self._create_kamil_adc_neutral_sets) + dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc) dialog.set_radar_config_summary( directory_path=self._preprocess_radar_scan_summary.directory_path, json_file_count=self._preprocess_radar_scan_summary.json_file_count, @@ -254,6 +258,7 @@ class AppWindowPreprocessMixin: f"{preprocess_asset_display_name(key)}={len(names)}" for key, names in available_sets.items() ) + dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc) self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}") if unavailable_selections: self._log_warning( @@ -338,6 +343,88 @@ class AppWindowPreprocessMixin: self._show_exception(f"Failed to start {kind} sequence", exc) self._resume_pipeline_if_needed() + def _create_kamil_adc_neutral_sets(self) -> None: + """Save neutral S21 calibration/reference sets for the current Kamil ADC settings.""" + if self._capture_session is not None: + self._show_error( + "Cannot create neutral sets during active capture sequence", + details=self._capture_state_details(), + ) + return + + dialog = self._ensure_preprocess_dialog() + set_name = dialog.set_name() + if not set_name: + self._show_error("Set name is required") + return + + pipeline_was_paused = False + try: + config = self._build_config() + if not config.is_kamil_adc: + self._show_error("Neutral S21 sets are available only for kamil_adc") + return + + radar_key = self._radar_key(config) + duplicate_assets = [ + preprocess_asset_display_name(key) + for key in ("s21_calibration", "s21_reference") + if set_name in self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key) + ] + if duplicate_assets: + raise RuntimeError( + f"Set '{set_name}' already exists for: " + ", ".join(duplicate_assets) + ) + + if self._supervisor.is_running(): + self._log("Pipeline paused for Kamil ADC neutral-set creation") + self._stop_run() + pipeline_was_paused = True + + point_count = self._read_kamil_adc_point_count(config) + calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count) + self._store.save_set("s21_calibration", radar_key, set_name, calibration) + self._store.save_set("s21_reference", radar_key, set_name, reference) + + self._selected_preprocess_sets["s21_calibration"] = set_name + self._selected_preprocess_sets["s21_reference"] = set_name + self._selected_preprocess_radar_key = radar_key + self._processor_run_signature = None + self._history_run_signature = None + self._reset_runtime_history() + self._refresh_sets() + dialog.set_status( + f"Neutral S21 sets saved: {set_name} ({len(calibration.traces)} combos, {point_count} points)" + ) + self._log( + "Kamil ADC neutral S21 sets saved: " + f"set={set_name}, radar_key={radar_key}, combos={len(calibration.traces)}, points={point_count}" + ) + except Exception as exc: # noqa: BLE001 + self._show_exception("Failed to create Kamil ADC neutral sets", exc) + finally: + if pipeline_was_paused: + self._start_run() + + def _read_kamil_adc_point_count(self, config) -> int: + """Read one Kamil ADC sweep and return its actual point count.""" + dialog = self._ensure_preprocess_dialog() + dialog.set_status("Reading one Kamil ADC sweep to detect point count...") + self._log("Reading one Kamil ADC sweep to detect neutral-set point count") + + radar = KamilAdcService(config) + try: + radar.open() + radar.configure(config.radar.sweep) + sweep = radar.acquire() + finally: + radar.close() + + point_count = int(sweep.x.size) + if point_count <= 0: + raise RuntimeError("Kamil ADC returned an empty sweep while detecting point count") + return point_count + def _build_single_radar_capture_session( self, *, diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 7d44ad7..7a5e4fb 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -209,6 +209,18 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_angle_comp_power.setSingleStep(0.01) owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power)) + owner._gpr_score_mode = QComboBox() + owner._gpr_score_mode.addItems(["peak", "combined"]) + owner._set_combo_current_text(owner._gpr_score_mode, gpr_live_defaults.score_mode) + + owner._gpr_max_detected_objects_to_draw = QSpinBox() + owner._gpr_max_detected_objects_to_draw.setRange(0, 10_000) + owner._gpr_max_detected_objects_to_draw.setValue(int(gpr_live_defaults.max_detected_objects_to_draw)) + + owner._gpr_draw_top_m_objects = QSpinBox() + 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_start_freq_mhz = QDoubleSpinBox() owner._gpr_start_freq_mhz.setDecimals(1) owner._gpr_start_freq_mhz.setRange(100.0, 8800.0) @@ -274,8 +286,11 @@ def build_processing_group(owner) -> QGroupBox: ("Max depth m", owner._gpr_max_depth_m), ("Range comp power", owner._gpr_range_comp_power), ("Angle comp power", owner._gpr_angle_comp_power), + ("Score mode", owner._gpr_score_mode), ("Render mode", owner._gpr_render_mode), ("Min visible score", owner._gpr_min_visible_score), + ("Max detected objects", owner._gpr_max_detected_objects_to_draw), + ("Draw top M objects", owner._gpr_draw_top_m_objects), ("Start MHz", owner._gpr_start_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz), ("Visible X min m", owner._gpr_visible_x_min_m), @@ -348,6 +363,11 @@ def build_processing_group(owner) -> QGroupBox: owner._legacy_gpr_speed_m_s.setSingleStep(0.01) owner._legacy_gpr_speed_m_s.setValue(float(legacy_gpr_defaults.speed_m_s)) + owner._legacy_gpr_ignore_socket_speed_enabled = QCheckBox("Ignore socket speed") + owner._legacy_gpr_ignore_socket_speed_enabled.setChecked( + bool(legacy_gpr_defaults.ignore_socket_speed_enabled) + ) + owner._legacy_gpr_look_angle_deg = QDoubleSpinBox() owner._legacy_gpr_look_angle_deg.setDecimals(2) owner._legacy_gpr_look_angle_deg.setRange(-90.0, 90.0) @@ -405,6 +425,7 @@ def build_processing_group(owner) -> QGroupBox: ("SNR thresh", owner._legacy_gpr_snr_thresh), ("SNR comp max", owner._legacy_gpr_snr_comp_max), ("Speed m/s", owner._legacy_gpr_speed_m_s), + owner._legacy_gpr_ignore_socket_speed_enabled, ("Render mode", owner._legacy_gpr_render_mode), ("Min visible pairs", owner._legacy_gpr_min_visible_pair_count), ("Start MHz", owner._legacy_gpr_start_freq_mhz), @@ -445,6 +466,7 @@ def build_processing_group(owner) -> QGroupBox: 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_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed) @@ -452,6 +474,8 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_remove_sidelobe_objects_enabled.toggled.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_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/gui/controllers/sections/radar_section.py b/python_app/gui/controllers/sections/radar_section.py index 65e824e..714f193 100644 --- a/python_app/gui/controllers/sections/radar_section.py +++ b/python_app/gui/controllers/sections/radar_section.py @@ -2,7 +2,18 @@ from __future__ import annotations -from PyQt6.QtWidgets import QGroupBox, QLabel, QLineEdit, QVBoxLayout +import json + +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QGroupBox, + QLabel, + QLineEdit, + QPlainTextEdit, + QVBoxLayout, + QWidget, +) from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget @@ -15,6 +26,11 @@ def build_radar_group(owner) -> QGroupBox: layout.setSpacing(8) defaults = owner._defaults_config.radar + owner._vna_radar_settings_panel = QWidget(group) + vna_layout = QVBoxLayout(owner._vna_radar_settings_panel) + vna_layout.setContentsMargins(0, 0, 0, 0) + vna_layout.setSpacing(8) + owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}") owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}") owner._points_input = QLineEdit(str(defaults.sweep.points)) @@ -36,7 +52,7 @@ def build_radar_group(owner) -> QGroupBox: owner._radar_limits_hint = QLabel("Device limits are not available.") owner._radar_limits_hint.setObjectName("hintLabel") - layout.addWidget( + vna_layout.addWidget( build_two_column_form_widget( group, [ @@ -48,5 +64,174 @@ def build_radar_group(owner) -> QGroupBox: ], ) ) - layout.addWidget(owner._radar_limits_hint) + vna_layout.addWidget(owner._radar_limits_hint) + + owner._adc_radar_settings_panel = _build_adc_settings_panel(owner, group) + + layout.addWidget(owner._vna_radar_settings_panel) + layout.addWidget(owner._adc_radar_settings_panel) + owner._set_radar_settings_mode(owner._defaults_config.is_kamil_adc) return group + + +def _build_adc_settings_panel(owner, parent: QWidget) -> QWidget: + """Create controls for the external ADC collector and optical board.""" + panel = QWidget(parent) + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + adc = owner._defaults_config.radar.kamil_adc + optical = owner._defaults_config.radar.laser_control + manual = optical.manual + variation = optical.variation + + owner._adc_project_dir_input = QLineEdit(adc.project_dir) + owner._adc_executable_path_input = QLineEdit(adc.executable_path) + owner._adc_tty_path_input = QLineEdit(adc.tty_path) + owner._adc_args_input = _plain_text("\n".join(adc.args)) + owner._adc_env_input = _plain_text(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}") + owner._adc_startup_timeout_s_input = QLineEdit(f"{adc.startup_timeout_s:g}") + owner._adc_sweep_timeout_s_input = QLineEdit(f"{adc.sweep_timeout_s:g}") + owner._adc_stop_timeout_s_input = QLineEdit(f"{adc.stop_timeout_s:g}") + + owner._optical_enabled_checkbox = QCheckBox() + owner._optical_enabled_checkbox.setChecked(bool(optical.enabled)) + owner._optical_port_input = QLineEdit(optical.port) + owner._optical_mode_combo = QComboBox() + owner._optical_mode_combo.addItems(["manual", "variation"]) + owner._set_combo_current_text(owner._optical_mode_combo, optical.mode) + owner._optical_pi_coeff1_p_input = QLineEdit(str(optical.pi_coeff1_p)) + owner._optical_pi_coeff1_i_input = QLineEdit(str(optical.pi_coeff1_i)) + owner._optical_pi_coeff2_p_input = QLineEdit(str(optical.pi_coeff2_p)) + owner._optical_pi_coeff2_i_input = QLineEdit(str(optical.pi_coeff2_i)) + + owner._optical_manual_temp1_input = QLineEdit(f"{manual.temp1:g}") + owner._optical_manual_temp2_input = QLineEdit(f"{manual.temp2:g}") + owner._optical_manual_current1_input = QLineEdit(f"{manual.current1:g}") + owner._optical_manual_current2_input = QLineEdit(f"{manual.current2:g}") + + owner._optical_variation_type_combo = QComboBox() + owner._optical_variation_type_combo.addItems( + [ + "CHANGE_CURRENT_LD1", + "CHANGE_CURRENT_LD2", + "CHANGE_TEMPERATURE_LD1", + "CHANGE_TEMPERATURE_LD2", + ] + ) + owner._set_combo_current_text(owner._optical_variation_type_combo, variation.variation_type) + owner._optical_static_temp1_input = QLineEdit(f"{variation.static_temp1:g}") + owner._optical_static_temp2_input = QLineEdit(f"{variation.static_temp2:g}") + owner._optical_static_current1_input = QLineEdit(f"{variation.static_current1:g}") + owner._optical_static_current2_input = QLineEdit(f"{variation.static_current2:g}") + owner._optical_min_value_input = QLineEdit(f"{variation.min_value:g}") + owner._optical_max_value_input = QLineEdit(f"{variation.max_value:g}") + owner._optical_step_input = QLineEdit(f"{variation.step:g}") + owner._optical_time_step_input = QLineEdit(str(variation.time_step)) + owner._optical_delay_time_input = QLineEdit(str(variation.delay_time)) + + layout.addWidget( + build_two_column_form_widget( + panel, + [ + ("Project dir", owner._adc_project_dir_input), + ("Executable path", owner._adc_executable_path_input), + ("TTY path", owner._adc_tty_path_input), + ("Arguments", owner._adc_args_input), + ("Environment JSON", owner._adc_env_input), + ("Startup timeout s", owner._adc_startup_timeout_s_input), + ("Read timeout s", owner._adc_sweep_timeout_s_input), + ("Stop timeout s", owner._adc_stop_timeout_s_input), + ("Board enabled", owner._optical_enabled_checkbox), + ("Board port", owner._optical_port_input), + ("Board mode", owner._optical_mode_combo), + ("PI 1 P", owner._optical_pi_coeff1_p_input), + ("PI 1 I", owner._optical_pi_coeff1_i_input), + ("PI 2 P", owner._optical_pi_coeff2_p_input), + ("PI 2 I", owner._optical_pi_coeff2_i_input), + ], + split_index=8, + ) + ) + + owner._optical_manual_panel = build_two_column_form_widget( + panel, + [ + ("Temperature 1", owner._optical_manual_temp1_input), + ("Temperature 2", owner._optical_manual_temp2_input), + ("Current 1", owner._optical_manual_current1_input), + ("Current 2", owner._optical_manual_current2_input), + ], + ) + owner._optical_variation_panel = build_two_column_form_widget( + panel, + [ + ("Variation type", owner._optical_variation_type_combo), + ("Static temp 1", owner._optical_static_temp1_input), + ("Static temp 2", owner._optical_static_temp2_input), + ("Static current 1", owner._optical_static_current1_input), + ("Static current 2", owner._optical_static_current2_input), + ("Min value", owner._optical_min_value_input), + ("Max value", owner._optical_max_value_input), + ("Step", owner._optical_step_input), + ("Time step", owner._optical_time_step_input), + ("Delay time", owner._optical_delay_time_input), + ], + split_index=5, + ) + layout.addWidget(owner._optical_manual_panel) + layout.addWidget(owner._optical_variation_panel) + + _connect_adc_settings(owner) + owner._sync_adc_settings_controls() + return panel + + +def _plain_text(text: str) -> QPlainTextEdit: + widget = QPlainTextEdit(text) + widget.setMaximumHeight(76) + return widget + + +def _connect_adc_settings(owner) -> None: + text_widgets = [ + owner._adc_project_dir_input, + owner._adc_executable_path_input, + owner._adc_tty_path_input, + owner._adc_startup_timeout_s_input, + owner._adc_sweep_timeout_s_input, + owner._adc_stop_timeout_s_input, + owner._optical_port_input, + owner._optical_pi_coeff1_p_input, + owner._optical_pi_coeff1_i_input, + owner._optical_pi_coeff2_p_input, + owner._optical_pi_coeff2_i_input, + owner._optical_manual_temp1_input, + owner._optical_manual_temp2_input, + owner._optical_manual_current1_input, + owner._optical_manual_current2_input, + owner._optical_static_temp1_input, + owner._optical_static_temp2_input, + owner._optical_static_current1_input, + owner._optical_static_current2_input, + owner._optical_min_value_input, + owner._optical_max_value_input, + owner._optical_step_input, + owner._optical_time_step_input, + owner._optical_delay_time_input, + ] + for widget in text_widgets: + widget.editingFinished.connect(owner._reset_preprocess_selection_after_radar_key_change) + owner._adc_args_input.textChanged.connect(owner._reset_preprocess_selection_after_radar_key_change) + owner._adc_env_input.textChanged.connect(owner._reset_preprocess_selection_after_radar_key_change) + owner._optical_variation_type_combo.currentTextChanged.connect( + owner._reset_preprocess_selection_after_radar_key_change + ) + + def sync_and_reset() -> None: + owner._sync_adc_settings_controls() + owner._reset_preprocess_selection_after_radar_key_change() + + owner._optical_enabled_checkbox.toggled.connect(sync_and_reset) + owner._optical_mode_combo.currentTextChanged.connect(sync_and_reset) diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index b6d8e9f..5f3ccec 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -44,6 +44,7 @@ class PreprocessDialog(QDialog): undo_last_requested = pyqtSignal() finalize_sequence_requested = pyqtSignal() abort_sequence_requested = pyqtSignal() + create_kamil_adc_neutral_sets_requested = pyqtSignal() def __init__(self, parent=None) -> None: """Initialize window metadata and compose dialog UI.""" @@ -88,8 +89,17 @@ class PreprocessDialog(QDialog): self._set_name_input = QLineEdit("set_001", group) refresh_button = QPushButton("Refresh Sets", group) refresh_button.clicked.connect(self.refresh_requested.emit) + self._kamil_adc_neutral_sets_button = QPushButton("Create Neutral S21 Sets", group) + self._kamil_adc_neutral_sets_button.setToolTip( + "Save S21 calibration=1 and S21 reference=0 for the current Kamil ADC settings." + ) + self._kamil_adc_neutral_sets_button.clicked.connect( + self.create_kamil_adc_neutral_sets_requested.emit + ) + self._kamil_adc_neutral_sets_button.setVisible(False) header_row.addWidget(QLabel("Set name")) header_row.addWidget(self._set_name_input, stretch=1) + header_row.addWidget(self._kamil_adc_neutral_sets_button) header_row.addWidget(refresh_button) layout.addLayout(header_row) layout.addWidget(self._build_radar_config_group(group)) @@ -369,6 +379,11 @@ class PreprocessDialog(QDialog): """Set short human-readable status line.""" self._status_label.setText(message) + def set_kamil_adc_neutral_sets_visible(self, visible: bool) -> None: + """Show Kamil ADC neutral-set shortcut only in the matching radar mode.""" + self._kamil_adc_neutral_sets_button.setVisible(bool(visible)) + self._kamil_adc_neutral_sets_button.setEnabled(bool(visible)) + def reset_preview(self) -> None: """Clear preview surface and restore default empty-state text when possible.""" if self._preview_plot is not None: diff --git a/python_app/gui/runtime/history.py b/python_app/gui/runtime/history.py index c01b68f..2fb2866 100644 --- a/python_app/gui/runtime/history.py +++ b/python_app/gui/runtime/history.py @@ -65,6 +65,7 @@ def build_run_history_signature( """Build deterministic signature to detect run-settings changes (excluding live processing params).""" combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos) preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS) + sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points) return ( str(config.radar.model), str(config.radar.driver_mode), @@ -73,7 +74,7 @@ def build_run_history_signature( bool(config.radar.multi_device.force_external_reference), float(config.radar.sweep.start_hz), float(config.radar.sweep.stop_hz), - int(config.radar.sweep.points), + sweep_points_signature, float(config.radar.sweep.if_bandwidth_hz), float(config.radar.sweep.power_dbm), str(config.input_switch.driver_mode), diff --git a/python_app/hardware_full/kamil_adc_service.py b/python_app/hardware_full/kamil_adc_service.py index 5bf5fcf..d0bc151 100644 --- a/python_app/hardware_full/kamil_adc_service.py +++ b/python_app/hardware_full/kamil_adc_service.py @@ -10,10 +10,10 @@ import os from pathlib import Path import select import signal +import stat import struct import subprocess import time -from typing import Any import numpy as np @@ -67,6 +67,7 @@ class KamilAdcTtyReader: tty_path: str _fd: int | None = field(init=False, default=None, repr=False) _buffer: bytearray = field(init=False, default_factory=bytearray, repr=False) + _packet_start_pending: bool = field(init=False, default=False, repr=False) def open(self) -> None: """Open the configured TTY path for binary reads.""" @@ -83,36 +84,67 @@ class KamilAdcTtyReader: finally: self._fd = None self._buffer.clear() + self._packet_start_pending = False def read_sweep( self, *, - points: int, timeout_s: float, process: subprocess.Popen[bytes] | None = None, + expected_points: int | None = None, ) -> np.ndarray: - """Read one packet-start marker followed by exactly `points` IQ frames.""" - if points <= 0: - raise ValueError("Kamil ADC sweep points must be > 0") - if points > KAMIL_ADC_MAX_STEP: - raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}") + """Read one full packet, optionally discarding packets with an unexpected point count.""" if self._fd is None: raise RuntimeError("Kamil ADC TTY reader is not open") + if expected_points is not None: + if expected_points <= 0: + raise ValueError("Kamil ADC expected points must be > 0") + if expected_points > KAMIL_ADC_MAX_STEP: + raise ValueError(f"Kamil ADC expected points must be <= {KAMIL_ADC_MAX_STEP}") deadline = time.monotonic() + float(timeout_s) - self._read_until_packet_start(deadline, process) + while True: + values = self._read_one_sweep(deadline, process) + if expected_points is None or int(values.size) == int(expected_points): + return values + logger.warning( + "Discarding Kamil ADC sweep with %d points; expected %d", + int(values.size), + int(expected_points), + ) - values = np.empty(points, dtype=np.complex64) - for index in range(points): - frame = self._read_frame(deadline, process, received_points=index, expected_points=points) - values[index] = KamilAdcFrameParser.parse_point(frame, index + 1) - return values + def _read_one_sweep( + self, + deadline: float, + process: subprocess.Popen[bytes] | None, + ) -> np.ndarray: + """Read one packet from start marker to the next start marker.""" + if self._packet_start_pending: + self._packet_start_pending = False + else: + self._read_until_packet_start(deadline, process) + + values: list[complex] = [] + expected_step = 1 + while True: + frame = self._read_frame(deadline, process, received_points=len(values)) + if KamilAdcFrameParser.is_packet_start(frame): + if not values: + continue + self._packet_start_pending = True + return np.asarray(values, dtype=np.complex64) + + if expected_step > KAMIL_ADC_MAX_STEP: + raise RuntimeError(f"Kamil ADC sweep exceeded {KAMIL_ADC_MAX_STEP} points without packet end") + values.append(KamilAdcFrameParser.parse_point(frame, expected_step)) + expected_step += 1 def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None: - """Discard bytes already buffered before starting a new logical sweep.""" + """Discard stale bytes while keeping the newest packet-start boundary.""" if self._fd is None: raise RuntimeError("Kamil ADC TTY reader is not open") self._buffer.clear() + self._packet_start_pending = False fd = self._require_fd() while True: self._raise_if_process_exited(process) @@ -132,6 +164,17 @@ class KamilAdcTtyReader: raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc if not chunk: raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining") + self._buffer.extend(chunk) + self._keep_latest_packet_start_tail() + + def _keep_latest_packet_start_tail(self) -> None: + """Keep only bytes from the latest complete packet-start marker onward.""" + start_index = self._buffer.rfind(_START_FRAME) + if start_index >= 0: + del self._buffer[:start_index] + return + if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES: + del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1] def _read_until_packet_start( self, @@ -153,7 +196,7 @@ class KamilAdcTtyReader: process: subprocess.Popen[bytes] | None, *, received_points: int, - expected_points: int, + expected_points: int | None = None, ) -> bytes: while len(self._buffer) < KAMIL_ADC_FRAME_BYTES: self._read_available(deadline, process, received_points, expected_points) @@ -172,6 +215,10 @@ class KamilAdcTtyReader: remaining_s = deadline - time.monotonic() if remaining_s <= 0.0: if received_points is None or expected_points is None: + if received_points is not None: + raise TimeoutError( + f"Timed out waiting for Kamil ADC sweep end: received {received_points} points" + ) raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker") raise TimeoutError( f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points" @@ -214,15 +261,14 @@ class KamilAdcTtyReader: @dataclass(slots=True) class KamilAdcService: - """Launch `kamil_adc`, configure laser board, and acquire TTY sweeps.""" + """Launch `kamil_adc` and acquire TTY sweeps.""" config: RunConfigModel _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False) _reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False) _settings: RadarSweepModel | None = field(init=False, default=None, repr=False) _frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False) - _laser_controller: Any | None = field(init=False, default=None, repr=False) - _laser_variation_active: bool = field(init=False, default=False, repr=False) + _expected_points: int | None = field(init=False, default=None, repr=False) def __post_init__(self) -> None: self._validate_config() @@ -235,13 +281,12 @@ class KamilAdcService: return [executable_path, *adc.args, f"tty:{adc.tty_path}"] def open(self) -> None: - """Apply laser configuration, launch the collector, and open its TTY stream.""" + """Launch the collector and open its TTY stream.""" if self._reader is not None: return - previous_tty_identity = _tty_identity(self.config.radar.kamil_adc.tty_path) + previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path) try: - self._apply_laser_control() self._start_process() self._wait_for_tty(previous_tty_identity) reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path) @@ -252,25 +297,20 @@ class KamilAdcService: raise def close(self) -> None: - """Close TTY, stop the external collector, and disconnect laser control.""" + """Close TTY and stop the external collector.""" if self._reader is not None: with suppress(Exception): self._reader.close() self._reader = None self._stop_process() - self._close_laser_control() def configure(self, sweep: RadarSweepModel) -> None: - """Store sweep settings and construct the synthetic frequency axis.""" + """Store sweep settings used to construct the synthetic frequency axis.""" self._validate_sweep(sweep) self._settings = sweep - self._frequency_hz = np.linspace( - float(sweep.start_hz), - float(sweep.stop_hz), - int(sweep.points), - dtype=np.float32, - ) + self._frequency_hz = None + self._expected_points = None def read_device_limits(self) -> dict[str, float | int]: """Kamil ADC has no runtime-readable sweep limit API.""" @@ -278,7 +318,7 @@ class KamilAdcService: def acquire(self) -> SweepResult: """Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros.""" - if self._settings is None or self._frequency_hz is None: + if self._settings is None: raise RuntimeError("Kamil ADC service is not configured") if self._reader is None: raise RuntimeError("Kamil ADC service is not open") @@ -287,13 +327,21 @@ class KamilAdcService: code = None if process is None else process.poll() raise RuntimeError(f"Kamil ADC process is not running (code={code})") - points = int(self._settings.points) self._reader.discard_pending(process) s21 = self._reader.read_sweep( - points=points, timeout_s=self.config.radar.kamil_adc.sweep_timeout_s, process=process, + expected_points=self._expected_points, ) + points = int(s21.size) + if points <= 0: + raise RuntimeError("Kamil ADC sweep contained no points") + if self._expected_points is None: + self._expected_points = points + self._frequency_hz = self._build_frequency_axis(points) + logger.info("Kamil ADC sweep point count locked to %d", points) + if self._frequency_hz is None: + self._frequency_hz = self._build_frequency_axis(points) return SweepResult( x=self._frequency_hz.copy(), traces={ @@ -353,79 +401,6 @@ class KamilAdcService: f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector" ) - def _apply_laser_control(self) -> None: - laser = self.config.radar.laser_control - if not laser.enabled: - return - - from python_app.hardware_full.laser_control.controller import LaserController - from python_app.hardware_full.laser_control.models import VariationType - - controller = LaserController( - port=laser.port, - pi_coeff1_p=laser.pi_coeff1_p, - pi_coeff1_i=laser.pi_coeff1_i, - pi_coeff2_p=laser.pi_coeff2_p, - pi_coeff2_i=laser.pi_coeff2_i, - ) - try: - controller.connect() - mode = laser.mode.strip().lower() - if mode == "manual": - manual = laser.manual - controller.set_manual_mode( - temp1=manual.temp1, - temp2=manual.temp2, - current1=manual.current1, - current2=manual.current2, - ) - elif mode == "variation": - variation = laser.variation - try: - variation_type = VariationType[variation.variation_type] - except KeyError as exc: - raise ValueError( - f"Unsupported radar.laser_control.variation.variation_type: " - f"{variation.variation_type}" - ) from exc - controller.start_variation( - variation_type=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, - }, - ) - self._laser_variation_active = True - else: - raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}") - except Exception: - with suppress(Exception): - controller.disconnect() - raise - - self._laser_controller = controller - - def _close_laser_control(self) -> None: - controller = self._laser_controller - self._laser_controller = None - if controller is None: - self._laser_variation_active = False - return - - try: - if self._laser_variation_active: - controller.stop_task() - finally: - self._laser_variation_active = False - controller.disconnect() - def _validate_config(self) -> None: if not self.config.is_kamil_adc: raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'") @@ -457,31 +432,33 @@ class KamilAdcService: if not os.access(executable_path, os.X_OK): raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}") - laser = self.config.radar.laser_control - if laser.enabled: - if not laser.port: - raise ValueError("radar.laser_control.port is required when laser_control is enabled") - mode = laser.mode.strip().lower() - if mode not in {"manual", "variation"}: - raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'") - if mode == "variation" and not laser.variation.variation_type: - raise ValueError("radar.laser_control.variation.variation_type is required") - @staticmethod def _validate_sweep(sweep: RadarSweepModel) -> None: - points = int(sweep.points) - if points <= 0: - raise ValueError("Kamil ADC sweep points must be > 0") - if points > KAMIL_ADC_MAX_STEP: - raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}") if float(sweep.stop_hz) < float(sweep.start_hz): raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz") + def _build_frequency_axis(self, points: int) -> np.ndarray: + if self._settings is None: + raise RuntimeError("Kamil ADC service is not configured") + return np.linspace( + float(self._settings.start_hz), + float(self._settings.stop_hz), + int(points), + dtype=np.float32, + ) + def _tty_identity(path: str) -> tuple[object, ...] | None: try: if os.path.islink(path): - return ("link", os.readlink(path)) + stat_result = os.lstat(path) + return ( + "link", + os.readlink(path), + int(stat_result.st_dev), + int(stat_result.st_ino), + int(stat_result.st_mtime_ns), + ) stat_result = os.stat(path) except FileNotFoundError: return None @@ -491,3 +468,95 @@ def _tty_identity(path: str) -> tuple[object, ...] | None: int(stat_result.st_ino), int(stat_result.st_mtime_ns), ) + + +def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None: + """Remove stale generated TTY links before starting the external collector.""" + try: + stat_result = os.lstat(path) + except FileNotFoundError: + return None + + if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode): + os.unlink(path) + return None + return None + + +def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool: + """Apply Kamil ADC laser settings exactly through the legacy device_main command sequence.""" + laser = config.radar.laser_control + if not laser.enabled: + return False + + _validate_laser_control_config(config) + + from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController + from python_app.hardware_full.laser_control.models import VariationType + + controller = LaserController( + port=laser.port, + pi_coeff1_p=laser.pi_coeff1_p, + pi_coeff1_i=laser.pi_coeff1_i, + pi_coeff2_p=laser.pi_coeff2_p, + pi_coeff2_i=laser.pi_coeff2_i, + ) + try: + controller.connect() + controller.reset() + mode = laser.mode.strip().lower() + if mode == "manual": + manual = laser.manual + controller.set_manual_mode( + temp1=manual.temp1, + temp2=manual.temp2, + current1=manual.current1, + current2=manual.current2, + message_id=DEVICE_MAIN_MESSAGE_ID, + ) + return True + if mode == "variation": + variation = laser.variation + try: + variation_type = VariationType[variation.variation_type] + except KeyError as exc: + raise ValueError( + f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}" + ) from exc + + 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=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, + }, + ) + return True + raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}") + finally: + controller.disconnect() + + +def _validate_laser_control_config(config: RunConfigModel) -> None: + laser = config.radar.laser_control + if not laser.port: + raise ValueError("radar.laser_control.port is required when laser_control is enabled") + mode = laser.mode.strip().lower() + if mode not in {"manual", "variation"}: + raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'") + if mode == "variation" and not laser.variation.variation_type: + raise ValueError("radar.laser_control.variation.variation_type is required") diff --git a/python_app/hardware_full/laser_control/controller.py b/python_app/hardware_full/laser_control/controller.py index 1fa1f07..56ee929 100644 --- a/python_app/hardware_full/laser_control/controller.py +++ b/python_app/hardware_full/laser_control/controller.py @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) # Default PI regulator coefficients (match firmware defaults) DEFAULT_PI_P = 2560 # 10 * 256 DEFAULT_PI_I = 128 # 0.5 * 256 +DEVICE_MAIN_MESSAGE_ID = 0x00FF class LaserController: @@ -121,6 +122,7 @@ class LaserController: temp2: float, current1: float, current2: float, + message_id: Optional[int] = None, ) -> None: """ Set manual control parameters for both lasers. @@ -134,6 +136,9 @@ class LaserController: Valid range: [15.0 … 60.0] mA. current2: Drive current for laser 2, mA. Valid range: [15.0 … 60.0] mA. + message_id: Optional fixed DECODE_ENABLE message id. When + omitted, the id is incremented for backward + compatibility with the refactored API. Raises: ValidationError: If any parameter is out of range. @@ -142,7 +147,10 @@ class LaserController: validated = ParameterValidator.validate_manual_mode_params( temp1, temp2, current1, current2 ) - self._message_id = (self._message_id + 1) & 0xFFFF + if message_id is None: + self._message_id = (self._message_id + 1) & 0xFFFF + else: + self._message_id = int(message_id) & 0xFFFF cmd = Protocol.encode_decode_enable( temp1=validated['temp1'], @@ -244,7 +252,7 @@ class LaserController: validated['max_value'], validated['step']) - def stop_task(self) -> None: + def stop_task(self, restore_message_id: Optional[int] = None) -> None: """Stop the current task and restore manual mode. Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last @@ -257,20 +265,13 @@ class LaserController: self._send_and_read_state(cmd_reset) logger.info("Task stopped (DEFAULT_ENABLE sent)") - # Restore manual mode so the board is ready for TRANS_ENABLE requests - self._message_id = (self._message_id + 1) & 0xFFFF - cmd_restore = Protocol.encode_decode_enable( + self.set_manual_mode( temp1=self._last_temp1, temp2=self._last_temp2, current1=self._last_current1, current2=self._last_current2, - pi_coeff1_p=self._pi1_p, - pi_coeff1_i=self._pi1_i, - pi_coeff2_p=self._pi2_p, - pi_coeff2_i=self._pi2_i, - message_id=self._message_id, + message_id=restore_message_id, ) - self._send_and_read_state(cmd_restore) logger.info("Manual mode restored after task stop") def get_measurements(self) -> Optional[Measurements]: @@ -380,4 +381,4 @@ class LaserController: except Exception: pass self.disconnect() - return False \ No newline at end of file + return False diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index e462a2b..627c78a 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -265,6 +265,24 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.angle_comp_power, "gui.processing.gpr", ), + score_mode=_optional_string( + gpr_object, + "score_mode", + gui.processing.gpr.score_mode, + "gui.processing.gpr", + ), + max_detected_objects_to_draw=_optional_int( + gpr_object, + "max_detected_objects_to_draw", + gui.processing.gpr.max_detected_objects_to_draw, + "gui.processing.gpr", + ), + draw_top_m_objects=_optional_int( + gpr_object, + "draw_top_m_objects", + gui.processing.gpr.draw_top_m_objects, + "gui.processing.gpr", + ), start_freq_mhz=_optional_float( gpr_object, "start_freq_mhz", @@ -342,6 +360,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: start_freq_mhz=legacy_float("start_freq_mhz", gui.processing.legacy_gpr.start_freq_mhz), stop_freq_mhz=legacy_float("stop_freq_mhz", gui.processing.legacy_gpr.stop_freq_mhz), speed_m_s=legacy_float("speed_m_s", gui.processing.legacy_gpr.speed_m_s), + ignore_socket_speed_enabled=legacy_bool( + "ignore_socket_speed_enabled", + gui.processing.legacy_gpr.ignore_socket_speed_enabled, + ), look_angle_deg=legacy_float("look_angle_deg", gui.processing.legacy_gpr.look_angle_deg), snr_thresh=legacy_float("snr_thresh", gui.processing.legacy_gpr.snr_thresh), snr_comp_max=legacy_float("snr_comp_max", gui.processing.legacy_gpr.snr_comp_max), @@ -370,6 +392,8 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase") if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}: raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only") + if gui.processing.gpr.score_mode not in {"peak", "combined"}: + raise ValueError("gui.processing.gpr.score_mode must be one of: peak, combined") if gui.processing.legacy_gpr.mode not in {"point", "extended"}: raise ValueError("gui.processing.legacy_gpr.mode must be one of: point, extended") if gui.processing.legacy_gpr.render_mode not in {"heatmap", "objects_only"}: @@ -380,6 +404,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: 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 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.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: @@ -474,6 +502,9 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "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, + "score_mode": gui.processing.gpr.score_mode, + "max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw, + "draw_top_m_objects": gui.processing.gpr.draw_top_m_objects, "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, @@ -496,6 +527,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "start_freq_mhz": gui.processing.legacy_gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.legacy_gpr.stop_freq_mhz, "speed_m_s": gui.processing.legacy_gpr.speed_m_s, + "ignore_socket_speed_enabled": gui.processing.legacy_gpr.ignore_socket_speed_enabled, "look_angle_deg": gui.processing.legacy_gpr.look_angle_deg, "snr_thresh": gui.processing.legacy_gpr.snr_thresh, "snr_comp_max": gui.processing.legacy_gpr.snr_comp_max, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index b7d1ae0..ef157e8 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -55,6 +55,9 @@ class GuiGprStateModel: max_depth_m: float = 14.0 range_comp_power: float = 0.28 angle_comp_power: float = 0.10 + score_mode: str = "combined" + max_detected_objects_to_draw: int = 5 + draw_top_m_objects: int = 2 start_freq_mhz: float = 3000.0 stop_freq_mhz: float = 6000.0 background_subtract_enabled: bool = True @@ -81,6 +84,7 @@ class GuiLegacyGprStateModel: start_freq_mhz: float = 3000.0 stop_freq_mhz: float = 6000.0 speed_m_s: float = 0.0 + ignore_socket_speed_enabled: bool = False look_angle_deg: float = 0.0 snr_thresh: float = 4.5 snr_comp_max: float = 25.0 diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 71986cf..218d26c 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -372,6 +372,15 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: """Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure.""" model.ensure_combos() + sweep_payload = { + "start_hz": model.radar.sweep.start_hz, + "stop_hz": model.radar.sweep.stop_hz, + "if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz, + "stimulus_power_dbm": model.radar.sweep.power_dbm, + } + if not model.is_kamil_adc: + sweep_payload["points"] = model.radar.sweep.points + return { "radar": { "model": model.radar.model, @@ -422,13 +431,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: "delay_time": model.radar.laser_control.variation.delay_time, }, }, - "sweep": { - "start_hz": model.radar.sweep.start_hz, - "stop_hz": model.radar.sweep.stop_hz, - "points": model.radar.sweep.points, - "if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz, - "stimulus_power_dbm": model.radar.sweep.power_dbm, - }, + "sweep": sweep_payload, }, "switches": { "port1": { diff --git a/python_app/orchestration/gpr_locator.py b/python_app/orchestration/gpr_locator.py index feffcb1..ad8f4ea 100644 --- a/python_app/orchestration/gpr_locator.py +++ b/python_app/orchestration/gpr_locator.py @@ -71,6 +71,7 @@ def locator_observations_from_collection( min_score: float, *, visible_bounds: tuple[float, float, float, float] | None = None, + object_draw_limits: tuple[int, int] | None = None, ) -> list[dict[str, float]]: """Build locator observations from GPR rows using score threshold and optional X/Z bounds.""" rows = gpr_object_rows(collection) @@ -88,6 +89,12 @@ def locator_observations_from_collection( & (rows[:, 1] <= z_max) ) filtered = rows[visible_mask] + if object_draw_limits is not None and filtered.size > 0: + max_detected_objects, draw_top_objects = object_draw_limits + if filtered.shape[0] > int(max_detected_objects): + filtered = np.zeros((0, filtered.shape[1]), dtype=filtered.dtype) + else: + filtered = filtered[: max(0, int(draw_top_objects))] observations: list[dict[str, float]] = [] for x_m, z_m, _score in filtered: diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 7d4aa0b..6a4b765 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -31,6 +31,9 @@ class ProcessingLiveConfig: gpr_range_comp_power: float = 0.28 gpr_angle_comp_power: float = 0.10 gpr_comp_power: float = 0.2 + gpr_score_mode: str = "combined" + gpr_max_detected_objects_to_draw: int = 5 + gpr_draw_top_m_objects: int = 2 gpr_speed_m_s: float = 0.0 gpr_look_angle_deg: float = 0.0 gpr_snr_thresh: float = 4.5 @@ -40,6 +43,7 @@ class ProcessingLiveConfig: gpr_background_subtract_enabled: bool = True gpr_background_mean_count: int = 10 gpr_remove_sidelobe_objects_enabled: bool = True + reprocess_current_result: bool = True history_command_seq: int = 0 history_command: str = "none" @@ -77,6 +81,9 @@ class ProcessingLiveConfig: "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_score_mode": str(self.gpr_score_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_speed_m_s": float(self.gpr_speed_m_s), "gpr_look_angle_deg": float(self.gpr_look_angle_deg), "gpr_snr_thresh": float(self.gpr_snr_thresh), @@ -86,6 +93,7 @@ class ProcessingLiveConfig: "gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled), "gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), + "reprocess_current_result": bool(self.reprocess_current_result), "history_command_seq": int(self.history_command_seq), "history_command": str(self.history_command), } diff --git a/python_app/orchestration/locator_runtime.py b/python_app/orchestration/locator_runtime.py index 664b33a..6344f79 100644 --- a/python_app/orchestration/locator_runtime.py +++ b/python_app/orchestration/locator_runtime.py @@ -63,6 +63,21 @@ def parse_vlc(payload: dict[str, Any]) -> float: return vlc +def format_payload_for_log(payload: Any) -> str: + """Return compact JSON-ish payload text for logs.""" + return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + + +def decode_packet_for_log(packet: bytes) -> tuple[int, str]: + """Decode an outbound packet into `(device_id, payload_text)` for logging.""" + if len(packet) < _PACKET_HEADER_STRUCT.size: + raise ValueError("Packet is shorter than the locator header") + header_bytes = packet[: _PACKET_HEADER_STRUCT.size] + payload_bytes = packet[_PACKET_HEADER_STRUCT.size :] + device_id, payload = decode_packet(header_bytes, payload_bytes) + return device_id, format_payload_for_log(payload) + + def format_peer_name(writer: asyncio.StreamWriter) -> str: """Return a readable peer address for logs.""" peer_name = writer.get_extra_info("peername") @@ -118,6 +133,7 @@ class LocatorTcpService: self._logger = logger or logging.getLogger(str(logger_name)) self._client_queue_size = int(client_queue_size) self._speed_updates: queue.Queue[float] = queue.Queue() + self._log_updates: queue.Queue[str] = queue.Queue() self._loop: asyncio.AbstractEventLoop | None = None self._server: asyncio.AbstractServer | None = None self._thread: threading.Thread | None = None @@ -186,12 +202,14 @@ class LocatorTcpService: min_score: float, *, visible_bounds: tuple[float, float, float, float] | None = None, + object_draw_limits: tuple[int, int] | None = None, ) -> None: """Publish one locator payload derived from a GPR result collection.""" observations = locator_observations_from_collection( collection, min_score, visible_bounds=visible_bounds, + object_draw_limits=object_draw_limits, ) payload = build_locator_payload( observations, @@ -218,6 +236,24 @@ class LocatorTcpService: except queue.Empty: return latest + def drain_log_updates(self) -> list[str]: + """Drain queued socket traffic log lines.""" + lines: list[str] = [] + while True: + try: + lines.append(str(self._log_updates.get_nowait())) + except queue.Empty: + return lines + + def _queue_log_update(self, message: str) -> None: + """Queue one socket traffic line for the GUI runtime log.""" + self._log_updates.put(str(message)) + + def _log_socket_traffic(self, message: str) -> None: + """Log socket traffic to both Python logging and the GUI-visible queue.""" + self._logger.info(message) + self._queue_log_update(message) + def _publish_packet(self, packet: bytes) -> None: """Store latest packet and broadcast it to all connected clients.""" with self._snapshot_lock: @@ -368,6 +404,17 @@ class LocatorTcpService: packet = await client.queue.get() client.writer.write(packet) await client.writer.drain() + try: + device_id, payload_text = decode_packet_for_log(packet) + self._log_socket_traffic( + "Locator socket sent to %s: device_id=%d payload=%s" + % (client.peer_name, device_id, payload_text) + ) + except ValueError as error: + self._log_socket_traffic( + "Locator socket sent undecodable packet to %s: bytes=%d error=%s" + % (client.peer_name, len(packet), error) + ) async def _receive_packets( self, @@ -377,21 +424,19 @@ class LocatorTcpService: """Receive inbound client packets and queue valid speed updates.""" while True: device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes) + payload_text = format_payload_for_log(payload) if isinstance(payload, dict) and "vlc" in payload: - self._speed_updates.put(parse_vlc(payload)) - self._logger.debug( - "Received locator speed from %s: device_id=%d payload=%s", - client.peer_name, - device_id, - payload, + speed_m_s = parse_vlc(payload) + self._speed_updates.put(speed_m_s) + self._log_socket_traffic( + "Locator socket received from %s: device_id=%d payload=%s speed_m_s=%g" + % (client.peer_name, device_id, payload_text, speed_m_s) ) continue - self._logger.info( - "Received locator payload from %s: device_id=%d payload=%s", - client.peer_name, - device_id, - json.dumps(payload, ensure_ascii=True, separators=(",", ":")), + self._log_socket_traffic( + "Locator socket received from %s: device_id=%d payload=%s" + % (client.peer_name, device_id, payload_text) ) def _broadcast_packet(self, packet: bytes) -> None: diff --git a/python_app/storage/npz/paths.py b/python_app/storage/npz/paths.py index 22a41e1..e11e0f6 100644 --- a/python_app/storage/npz/paths.py +++ b/python_app/storage/npz/paths.py @@ -23,12 +23,13 @@ def radar_key_from_config( serial_part = "_".join(sanitize_path_component(value) for value in serial_parts) start_token = _format_float_for_key(sweep_start_hz) stop_token = _format_float_for_key(sweep_stop_hz) + points_token = "adc" if model_name.strip().lower() == "kamil_adc" else str(int(sweep_points)) ifbw_token = _format_float_for_key(ifbw_hz) power_token = _format_float_for_key(power_dbm) return ( f"{model_name}_{serial_part}" f"_st{start_token}_sp{stop_token}" - f"_p{sweep_points}_if{ifbw_token}_pw{power_token}" + f"_p{points_token}_if{ifbw_token}_pw{power_token}" ) diff --git a/python_app/tests/test_kamil_adc_neutral_preprocess.py b/python_app/tests/test_kamil_adc_neutral_preprocess.py new file mode 100644 index 0000000..cbc1c57 --- /dev/null +++ b/python_app/tests/test_kamil_adc_neutral_preprocess.py @@ -0,0 +1,65 @@ +"""Tests for Kamil ADC neutral preprocessing-set generation.""" + +from __future__ import annotations + +import unittest + +import numpy as np + +from python_app.models.run_config_model import RunConfigModel +from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets + + +class KamilAdcNeutralPreprocessTest(unittest.TestCase): + def test_builds_passthrough_s21_sets_for_current_sweep(self) -> None: + config = RunConfigModel.from_dict( + { + "radar": { + "model": "kamil_adc", + "sweep": { + "start_hz": 1_000_000.0, + "stop_hz": 4_000_000.0, + "if_bandwidth_hz": 1.0, + "stimulus_power_dbm": -10.0, + }, + }, + "switches": { + "port1": {"positions": 1}, + "port2": {"positions": 2}, + }, + "run": { + "combos": [ + {"input": 0, "output": 0}, + {"input": 1, "output": 0}, + ], + }, + } + ) + + calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count=4) + + expected_frequency = np.linspace(1_000_000.0, 4_000_000.0, 4, dtype=np.float32) + self.assertEqual(len(calibration.traces), 2) + self.assertEqual(len(reference.traces), 2) + self.assertEqual( + [(trace.combo.input_pos, trace.combo.output_pos) for trace in calibration.traces], + [(0, 0), (1, 0)], + ) + for trace in calibration.traces: + np.testing.assert_array_equal(trace.frequency_hz, expected_frequency) + np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64)) + np.testing.assert_array_equal(trace.s21, np.ones(4, dtype=np.complex64)) + for trace in reference.traces: + np.testing.assert_array_equal(trace.frequency_hz, expected_frequency) + np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64)) + np.testing.assert_array_equal(trace.s21, np.zeros(4, dtype=np.complex64)) + + def test_rejects_non_kamil_config(self) -> None: + config = RunConfigModel.from_dict({"radar": {"model": "librevna"}}) + + with self.assertRaisesRegex(ValueError, "kamil_adc"): + build_kamil_adc_neutral_s21_sets(config, point_count=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/tests/test_kamil_adc_service.py b/python_app/tests/test_kamil_adc_service.py index c1d6415..0c1ea3f 100644 --- a/python_app/tests/test_kamil_adc_service.py +++ b/python_app/tests/test_kamil_adc_service.py @@ -47,9 +47,15 @@ class KamilAdcTtyReaderTest(unittest.TestCase): tty.setraw(slave_fd) reader = KamilAdcTtyReader(os.ttyname(slave_fd)) reader.open() - os.write(master_fd, _start_frame() + _point_frame(1, 10, -1) + _point_frame(2, -20, 2)) + os.write( + master_fd, + _start_frame() + + _point_frame(1, 10, -1) + + _point_frame(2, -20, 2) + + _start_frame(), + ) - values = reader.read_sweep(points=2, timeout_s=1.0) + values = reader.read_sweep(timeout_s=1.0) self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)]) finally: @@ -58,7 +64,61 @@ class KamilAdcTtyReaderTest(unittest.TestCase): os.close(master_fd) os.close(slave_fd) - def test_short_stream_times_out_with_received_count(self) -> None: + def test_stream_reads_consecutive_variable_length_sweeps(self) -> None: + master_fd, slave_fd = pty.openpty() + reader: KamilAdcTtyReader | None = None + try: + tty.setraw(slave_fd) + reader = KamilAdcTtyReader(os.ttyname(slave_fd)) + reader.open() + os.write( + master_fd, + _start_frame() + + _point_frame(1, 10, -1) + + _point_frame(2, -20, 2) + + _start_frame() + + _point_frame(1, 30, -3) + + _start_frame(), + ) + + first = reader.read_sweep(timeout_s=1.0) + second = reader.read_sweep(timeout_s=1.0) + + self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)]) + self.assertEqual(second.tolist(), [complex(30, -3)]) + finally: + if reader is not None: + reader.close() + os.close(master_fd) + os.close(slave_fd) + + def test_expected_point_count_discards_mismatched_sweep(self) -> None: + master_fd, slave_fd = pty.openpty() + reader: KamilAdcTtyReader | None = None + try: + tty.setraw(slave_fd) + reader = KamilAdcTtyReader(os.ttyname(slave_fd)) + reader.open() + os.write( + master_fd, + _start_frame() + + _point_frame(1, 5, -5) + + _start_frame() + + _point_frame(1, 10, -1) + + _point_frame(2, -20, 2) + + _start_frame(), + ) + + values = reader.read_sweep(timeout_s=1.0, expected_points=2) + + self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)]) + finally: + if reader is not None: + reader.close() + os.close(master_fd) + os.close(slave_fd) + + def test_stream_without_next_start_times_out_with_received_count(self) -> None: master_fd, slave_fd = pty.openpty() reader: KamilAdcTtyReader | None = None try: @@ -67,8 +127,8 @@ class KamilAdcTtyReaderTest(unittest.TestCase): reader.open() os.write(master_fd, _start_frame() + _point_frame(1, 10, -1)) - with self.assertRaisesRegex(TimeoutError, "received 1/2"): - reader.read_sweep(points=2, timeout_s=0.05) + with self.assertRaisesRegex(TimeoutError, "sweep end: received 1 points"): + reader.read_sweep(timeout_s=0.05) finally: if reader is not None: reader.close() @@ -137,6 +197,7 @@ class KamilAdcConfigTest(unittest.TestCase): encoded = RunConfigModel.from_dict(payload).to_dict() self.assertEqual(encoded["radar"]["model"], "kamil_adc") + self.assertNotIn("points", encoded["radar"]["sweep"]) self.assertEqual(encoded["radar"]["kamil_adc"]["tty_path"], "/tmp/ttyADC_data") self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"]) self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"}) diff --git a/python_app/tests/test_laser_control_protocol.py b/python_app/tests/test_laser_control_protocol.py new file mode 100644 index 0000000..2e40a93 --- /dev/null +++ b/python_app/tests/test_laser_control_protocol.py @@ -0,0 +1,250 @@ +"""Laser-control protocol compatibility tests.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +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.protocol import Protocol, TaskType +from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control +from python_app.models.run_config_model import RunConfigModel + + +DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc2c163f" +DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = ( + "7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600" +) +DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = ( + "7777ff3702003d2acc2c0500881318ab3d2affa50a00000a8000000a80005106" +) + + +class _FakeProtocol: + def __init__(self) -> None: + self.is_connected = True + self.sent: list[bytes] = [] + + def send_raw(self, data: bytes) -> None: + self.sent.append(bytes(data)) + + def receive_raw(self, length: int) -> bytes: + return b"\x00\x00" if length == 2 else b"" + + +class _FakeLaserController: + instances: list["_FakeLaserController"] = [] + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + self.calls: list[tuple[str, object]] = [] + _FakeLaserController.instances.append(self) + + def connect(self) -> bool: + self.calls.append(("connect", None)) + return True + + def reset(self) -> None: + self.calls.append(("reset", None)) + + def set_manual_mode(self, **kwargs: object) -> None: + self.calls.append(("set_manual_mode", kwargs)) + + def start_variation(self, **kwargs: object) -> None: + self.calls.append(("start_variation", kwargs)) + + def disconnect(self) -> None: + self.calls.append(("disconnect", None)) + + +class LaserControlProtocolCompatibilityTest(unittest.TestCase): + def setUp(self) -> None: + _FakeLaserController.instances.clear() + + @staticmethod + def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel: + return RunConfigModel.from_dict( + { + "radar": { + "model": "kamil_adc", + "driver_mode": "native", + "laser_control": laser_payload, + } + } + ) + + def test_manual_command_matches_device_main_bytes(self) -> None: + command = Protocol.encode_decode_enable( + temp1=28.0, + temp2=28.9, + current1=33.0, + current2=35.0, + pi_coeff1_p=2560, + pi_coeff1_i=128, + pi_coeff2_p=2560, + pi_coeff2_i=128, + message_id=DEVICE_MAIN_MESSAGE_ID, + ) + + self.assertEqual(command.hex(), DEVICE_MAIN_MANUAL_HEX) + + def test_variation_commands_match_device_main_bytes(self) -> None: + ld1_command = Protocol.encode_task_enable( + task_type=TaskType.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=35.0, + step=0.05, + time_step=50, + delay_time=10, + message_id=DEVICE_MAIN_MESSAGE_ID, + pi_coeff1_p=2560, + pi_coeff1_i=128, + pi_coeff2_p=2560, + pi_coeff2_i=128, + ) + ld2_command = Protocol.encode_task_enable( + task_type=TaskType.CHANGE_CURRENT_LD2, + static_temp1=28.0, + static_temp2=28.9, + static_current1=33.0, + static_current2=35.0, + min_value=33.0, + max_value=35.0, + step=0.05, + time_step=50, + delay_time=10, + message_id=DEVICE_MAIN_MESSAGE_ID, + pi_coeff1_p=2560, + pi_coeff1_i=128, + pi_coeff2_p=2560, + pi_coeff2_i=128, + ) + + self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX) + self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX) + + def test_start_sequence_matches_device_main_order(self) -> None: + fake_protocol = _FakeProtocol() + controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) + controller._protocol = fake_protocol + + with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None): + controller.reset() + controller.set_manual_mode( + temp1=28.0, + temp2=28.9, + current1=33.0, + current2=35.0, + message_id=DEVICE_MAIN_MESSAGE_ID, + ) + controller.start_variation( + variation_type=VariationType.CHANGE_CURRENT_LD1, + params={ + "static_temp1": 28.0, + "static_temp2": 28.9, + "static_current1": 33.0, + "static_current2": 35.0, + "min_value": 33.0, + "max_value": 35.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10, + }, + ) + + self.assertEqual( + [command.hex() for command in fake_protocol.sent], + [ + "2222", + DEVICE_MAIN_MANUAL_HEX, + DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX, + ], + ) + + def test_stop_sequence_restores_device_main_manual_bytes(self) -> None: + fake_protocol = _FakeProtocol() + controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) + controller._protocol = fake_protocol + + with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None): + controller.set_manual_mode( + temp1=28.0, + temp2=28.9, + current1=33.0, + current2=35.0, + message_id=DEVICE_MAIN_MESSAGE_ID, + ) + fake_protocol.sent.clear() + controller.stop_task(restore_message_id=DEVICE_MAIN_MESSAGE_ID) + + self.assertEqual( + [command.hex() for command in fake_protocol.sent], + [ + "2222", + DEVICE_MAIN_MANUAL_HEX, + ], + ) + + def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None: + config = self._kamil_config( + { + "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": 35.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10, + }, + } + ) + + with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController): + applied = apply_kamil_adc_laser_control(config) + + self.assertTrue(applied) + controller = _FakeLaserController.instances[0] + self.assertEqual( + [name for name, _payload in controller.calls], + ["connect", "reset", "set_manual_mode", "start_variation", "disconnect"], + ) + manual_payload = controller.calls[2][1] + self.assertEqual( + manual_payload, + { + "temp1": 28.0, + "temp2": 28.9, + "current1": 33.0, + "current2": 35.0, + "message_id": DEVICE_MAIN_MESSAGE_ID, + }, + ) + + def test_apply_radar_skips_disabled_laser_control(self) -> None: + config = self._kamil_config({"enabled": False}) + + with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController): + applied = apply_kamil_adc_laser_control(config) + + self.assertFalse(applied) + self.assertEqual(_FakeLaserController.instances, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/workflows/kamil_adc_neutral_preprocess.py b/python_app/workflows/kamil_adc_neutral_preprocess.py new file mode 100644 index 0000000..72efd24 --- /dev/null +++ b/python_app/workflows/kamil_adc_neutral_preprocess.py @@ -0,0 +1,77 @@ +"""Neutral preprocessing-set helpers for Kamil ADC acquisition.""" + +from __future__ import annotations + +import time + +import numpy as np + +from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData +from python_app.models.run_config_model import ComboModel, RunConfigModel + + +def build_kamil_adc_neutral_s21_sets( + config: RunConfigModel, + point_count: int, +) -> tuple[SweepCollection, SweepCollection]: + """Build S21 calibration/reference collections that leave input S21 unchanged.""" + if not config.is_kamil_adc: + raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'") + + points = int(point_count) + if points <= 0: + raise ValueError("Kamil ADC neutral set point count must be > 0") + + combos = list(config.combos) + if not combos: + combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) + if not combos: + raise ValueError("Kamil ADC neutral sets require at least one switch combo") + + frequency_hz = np.linspace( + float(config.radar.sweep.start_hz), + float(config.radar.sweep.stop_hz), + points, + dtype=np.float32, + ) + now_ns = time.monotonic_ns() + calibration = _neutral_collection( + combos=combos, + frequency_hz=frequency_hz, + s21_value=np.complex64(1.0 + 0.0j), + monotonic_ns=now_ns, + ) + reference = _neutral_collection( + combos=combos, + frequency_hz=frequency_hz, + s21_value=np.complex64(0.0 + 0.0j), + monotonic_ns=now_ns, + ) + return calibration, reference + + +def _neutral_collection( + *, + combos: list[ComboModel], + frequency_hz: np.ndarray, + s21_value: np.complex64, + monotonic_ns: int, +) -> SweepCollection: + traces = [] + for combo in combos: + point_count = int(frequency_hz.size) + traces.append( + TraceData( + combo=ComboKey(input_pos=int(combo.input), output_pos=int(combo.output)), + frequency_hz=frequency_hz.copy(), + s11=np.zeros(point_count, dtype=np.complex64), + s21=np.full(point_count, s21_value, dtype=np.complex64), + ) + ) + return SweepCollection( + collection_id=1, + monotonic_ns=monotonic_ns, + traces=traces, + capture_start_ns=monotonic_ns, + capture_end_ns=monotonic_ns, + ) diff --git a/run_config_kamil_adc.example.json b/run_config_kamil_adc.example.json index 4ca5ff5..50a7c67 100644 --- a/run_config_kamil_adc.example.json +++ b/run_config_kamil_adc.example.json @@ -11,22 +11,12 @@ }, "kamil_adc": { "project_dir": "/home/europa/Documents/kamil_adc", - "executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture", + "executable_path": "/home/europa/Documents/kamil_adc/run_do1_pair_subtract_avg.sh", "tty_path": "/tmp/ttyADC_data", - "args": [ - "profile:phase", - "clock:internal", - "internal_ref_hz:2000000", - "mode:diff", - "channels:2", - "ch1:2", - "ch2:3", - "do1_toggle_per_frame", - "do1_pair_subtract_avg" - ], + "args": [], "env": {}, "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, + "sweep_timeout_s": 15.0, "stop_timeout_s": 2.0 }, "laser_control": { @@ -59,7 +49,6 @@ "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 201, "if_bandwidth_hz": 50000.0, "stimulus_power_dbm": -10.0 } diff --git a/run_config_kamil_adc.pi.json b/run_config_kamil_adc.pi.json new file mode 100644 index 0000000..1a42784 --- /dev/null +++ b/run_config_kamil_adc.pi.json @@ -0,0 +1,140 @@ +{ + "radar": { + "model": "kamil_adc", + "serial": "kamil_adc", + "driver_mode": "native", + "kamil_adc": { + "project_dir": "/home/callisto/Documents/kamil_adc", + "executable_path": "/home/callisto/Documents/kamil_adc/run_do1_pair_subtract_avg.sh", + "tty_path": "/tmp/ttyADC_data", + "args": [], + "env": { + "TTY_PATH": "/tmp/ttyADC_data" + }, + "startup_timeout_s": 10.0, + "sweep_timeout_s": 15.0, + "stop_timeout_s": 2.0 + }, + "laser_control": { + "enabled": false, + "port": "/dev/ttyUSB0", + "mode": "variation" + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 1, + "positions": 1, + "default_position": 0 + }, + "port2": { + "name": "port2", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 2, + "positions": 1, + "default_position": 0 + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": true, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "combos": [ + { + "input": 0, + "output": 0 + } + ] + }, + "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": false, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } + }, + "gpr": { + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.0 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": 0.0 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw_kamil_adc", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap_kamil_adc", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed_kamil_adc", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap_kamil_adc", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results_kamil_adc", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +} diff --git a/start.sh b/start.sh index 0f1e13f..b7e82ea 100755 --- a/start.sh +++ b/start.sh @@ -7,20 +7,29 @@ VENV_PYTHON="${PROJECT_ROOT}/.venv/bin/python" VENV_PIP="${PROJECT_ROOT}/.venv/bin/pip" GUI_ENTRY="${PROJECT_ROOT}/python_app/gui/main.py" REQUIREMENTS_FILE="${PROJECT_ROOT}/requirements.txt" +PYTHON_CMD="" +PROFILE_PATH="" SKIP_BUILD=0 BUILD_ONLY=0 CLEAN_SHM=0 +KAMIL_ADC_MODE=0 +AUTO_START=0 +PRODUCER_ONLY=0 print_usage() { cat <<'EOF' Usage: ./start.sh [options] Options: - --skip-build Skip C++ build step and only run GUI - --build-only Build C++ binaries and exit - --clean-shm Remove known radar shared-memory segments before start - -h, --help Show this help + --kamil-adc Use the Raspberry Pi Kamil ADC profile + --profile PATH Use a specific GUI/run config profile + --auto-start Start the GUI pipeline automatically after launch + --producer-only Run only the raw producer selected by the profile + --skip-build Skip C++ build step + --build-only Build C++ binaries and exit + --clean-shm Remove known radar shared-memory segments before start + -h, --help Show this help EOF } @@ -36,6 +45,23 @@ parse_args() { --clean-shm) CLEAN_SHM=1 ;; + --kamil-adc) + KAMIL_ADC_MODE=1 + ;; + --profile) + if (($# < 2)); then + echo "--profile requires a path argument." >&2 + exit 1 + fi + PROFILE_PATH="$2" + shift + ;; + --auto-start) + AUTO_START=1 + ;; + --producer-only) + PRODUCER_ONLY=1 + ;; -h|--help) print_usage exit 0 @@ -50,6 +76,35 @@ parse_args() { done } +absolute_path() { + local path="$1" + if [[ "${path}" = /* ]]; then + printf '%s\n' "${path}" + return + fi + printf '%s\n' "${PROJECT_ROOT}/${path}" +} + +resolve_profile_path() { + if ((KAMIL_ADC_MODE == 1)); then + if [[ -z "${PROFILE_PATH}" ]]; then + if [[ -f "${PROJECT_ROOT}/run_config_kamil_adc.pi.json" ]]; then + PROFILE_PATH="${PROJECT_ROOT}/run_config_kamil_adc.pi.json" + else + PROFILE_PATH="${PROJECT_ROOT}/run_config_kamil_adc.example.json" + fi + fi + fi + + if [[ -n "${PROFILE_PATH}" ]]; then + PROFILE_PATH="$(absolute_path "${PROFILE_PATH}")" + if [[ ! -f "${PROFILE_PATH}" ]]; then + echo "Config profile not found: ${PROFILE_PATH}" >&2 + exit 1 + fi + fi +} + check_environment() { if ! command -v python3 >/dev/null 2>&1; then echo "python3 is not installed or not found in PATH." >&2 @@ -68,6 +123,13 @@ check_environment() { } ensure_python_dependencies() { + local dependency_check + if ((KAMIL_ADC_MODE == 1)); then + dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1' + else + dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1, pyvisa' + fi + if [[ ! -x "${VENV_PYTHON}" ]]; then echo "[start.sh] Creating virtual environment..." python3 -m venv "${PROJECT_ROOT}/.venv" @@ -78,9 +140,19 @@ ensure_python_dependencies() { exit 1 fi - echo "[start.sh] Installing Python dependencies..." - "${VENV_PIP}" install --upgrade pip - "${VENV_PIP}" install -r "${REQUIREMENTS_FILE}" + if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then + echo "[start.sh] Installing Python dependencies into virtual environment..." + "${VENV_PIP}" install --upgrade pip + "${VENV_PIP}" install -r "${REQUIREMENTS_FILE}" + fi + + if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then + echo "Required Python dependencies are still unavailable in virtual environment: ${PROJECT_ROOT}/.venv" >&2 + exit 1 + fi + + PYTHON_CMD="${VENV_PYTHON}" + echo "[start.sh] Using virtual environment: ${PYTHON_CMD}" } run_privileged() { @@ -170,19 +242,57 @@ build_cpp_binaries() { cleanup_known_shm() { echo "[start.sh] Cleaning known shared-memory segments..." - rm -f /dev/shm/radar_raw /dev/shm/radar_preprocessed /dev/shm/radar_results || true + rm -f \ + /dev/shm/radar_raw \ + /dev/shm/radar_raw_tap \ + /dev/shm/radar_preprocessed \ + /dev/shm/radar_preprocessed_tap \ + /dev/shm/radar_results \ + /dev/shm/radar_raw_kamil_adc \ + /dev/shm/radar_raw_tap_kamil_adc \ + /dev/shm/radar_preprocessed_kamil_adc \ + /dev/shm/radar_preprocessed_tap_kamil_adc \ + /dev/shm/radar_results_kamil_adc \ + || true } run_gui() { + if [[ -n "${PROFILE_PATH}" ]]; then + export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}" + echo "[start.sh] Using config profile: ${PROFILE_PATH}" + fi + if ((AUTO_START == 1)); then + export RADAR_SYSTEM_AUTO_START=1 + echo "[start.sh] GUI auto-start is enabled." + fi + echo "[start.sh] Launching GUI..." - exec "${VENV_PYTHON}" "${GUI_ENTRY}" + exec "${PYTHON_CMD}" "${GUI_ENTRY}" +} + +run_producer_only() { + if ((KAMIL_ADC_MODE != 1)); then + echo "--producer-only currently requires --kamil-adc." >&2 + exit 1 + fi + if [[ -z "${PROFILE_PATH}" ]]; then + echo "--producer-only requires a resolved config profile." >&2 + exit 1 + fi + + export PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" + echo "[start.sh] Starting Kamil ADC raw producer with profile: ${PROFILE_PATH}" + exec "${PYTHON_CMD}" -m python_app.scripts.kamil_adc_raw_producer --config "${PROFILE_PATH}" } main() { parse_args "$@" + resolve_profile_path check_environment - ensure_system_dependencies - ensure_usb_access_rules + if ((KAMIL_ADC_MODE == 0)); then + ensure_system_dependencies + ensure_usb_access_rules + fi ensure_python_dependencies if ((CLEAN_SHM == 1)); then @@ -198,6 +308,10 @@ main() { exit 0 fi + if ((PRODUCER_ONLY == 1)); then + run_producer_only + fi + run_gui } diff --git a/tmp b/tmp deleted file mode 100644 index 6439c3f..0000000 --- a/tmp +++ /dev/null @@ -1,3 +0,0 @@ -смотри сейчас будем добавлять в проект поддержку еще одного девайса в качестве радара. Когда будешь читать код смотри если есть файлы длинее чем 1300 строк то надо бы будет их грамотно разбить. В целом пиши код как мастер профессионал лучший в мире и самый опытный разработчик, пиши красивейший код, максимально читаемый, грамотный и понятный. Очень внимательнно смотри чтобы не было фолбеков, если в коде уже сейчас видишь какие то фолбеки то скажи где они и что делают, скорее всего будем удалять их в дальнейшем. И когда писать сейчас будешь то не создавай лишнего кода типа фолбеков изза отсутвтивия зависимостей и так далее, лишний код это плохо. объем в идеале уменьшать надо проекта. - -Давай постепенно будем добавлять поддержку нового типа радара в код, для начала - сбор данных свипов. По пути /home/europa/Documents/kamil_adc лежит проект, который собирает свипы с нового девайса (там формат получается вроде бы как 0x0a step data1 data2 где data 1 это действтиельная часть а 2 это мнимая). Вот надо будет драйвер написать для интеграции в проект. как в случае с мультидевайсом можно только питоновский драйвер оставить, то есть будет запускаться код например из проекта kamil_adc а мы поверх него уже пишем нашу прослойку для интеграции в проект radar_system. Вот желательно немного кода добавтиь. И еще у этого девайса своя конфигурация, увидеть как настраивается устройство и какие параметры можно в проекте \ No newline at end of file