From e202f57ef822badc26cb098d01dfafb1a091513b Mon Sep 17 00:00:00 2001 From: awe Date: Thu, 17 Sep 2026 17:58:36 +0300 Subject: [PATCH] move to new version --- Makefile | 16 +- .../include/processing_live_config.hpp | 21 + .../src/processing_live_config.cpp | 53 +++ .../src/gpr_backprojection_processor.ipp | 369 +++++++++++++++--- .../processors/src/gpr_legacy_processor.ipp | 7 +- .../processors/src/gpr_processor.cpp | 1 + .../tools/gpr_timing_check_main.cpp | 170 ++++++++ .../live_processing_mixin.py | 6 + .../app_window_config/profile_io_mixin.py | 16 + .../app_window_config/state_builders.py | 6 + .../sections/processing_section.py | 64 ++- python_app/models/gui_profile_codec.py | 52 +++ python_app/models/gui_profile_schema.py | 12 + .../orchestration/live_processing_config.py | 25 ++ .../scripts/gpr_timing_check_reference.py | 101 +++++ python_app/tests/test_gui_profile_codec.py | 12 + 16 files changed, 864 insertions(+), 67 deletions(-) create mode 100644 data_acq_and_processing/processing/tools/gpr_timing_check_main.cpp create mode 100644 python_app/scripts/gpr_timing_check_reference.py diff --git a/Makefile b/Makefile index 1f4f621..bc429ce 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ TARGETS := \ $(BIN_DIR)/data_preprocessor \ $(BIN_DIR)/data_processor -.PHONY: all clean kamil_adc_collector sar3d_parity +.PHONY: all clean kamil_adc_collector sar3d_parity gpr_timing_check all: $(TARGETS) @@ -137,6 +137,20 @@ $(BIN_DIR)/sar3d_parity: $(SAR3D_PARITY_OBJS) $(GPR_CPP_LIB) @mkdir -p $(BIN_DIR) $(CXX) $(SAR3D_PARITY_OBJS) -o $@ $(DATA_PROCESSOR_LDFLAGS) +# On-demand numeric check of the backprojection frame-timing model against the +# Multi_antenna_3D notebook (scripts/gpr_timing_check_reference.py prints the +# same numbers from the notebook formulas). Not part of `all`. +GPR_TIMING_CHECK_OBJS := $(addprefix $(BUILD_DIR)/, \ + data_acq_and_processing/processing/tools/gpr_timing_check_main.o \ + data_acq_and_processing/common_cpp/ipc/src/shared_types.o \ + data_acq_and_processing/common_cpp/config/src/run_config.o) + +gpr_timing_check: $(BIN_DIR)/gpr_timing_check + +$(BIN_DIR)/gpr_timing_check: $(GPR_TIMING_CHECK_OBJS) + @mkdir -p $(BIN_DIR) + $(CXX) $(GPR_TIMING_CHECK_OBJS) -o $@ $(LDFLAGS) + $(BIN_DIR)/sweep_orchestrator: $(SWEEP_ORCH_OBJS) @mkdir -p $(BIN_DIR) $(CXX) $(SWEEP_ORCH_OBJS) -o $@ $(ORCH_LDFLAGS) 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 d55a605..2123074 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 @@ -74,7 +74,28 @@ struct ProcessingLiveConfig { bool gpr_remove_sidelobe_objects_enabled = true; // BP image is computed in the y=imaging_plane_y_m slice of the 3D grid. // Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane. + // Used only when gpr_plane_mode == "const". float gpr_imaging_plane_y_m = 0.0F; + // Acquisition timing model for motion correction (notebook + // FREQUENCY_TIMING_MODE): "segmented_interleaved" — Tx outputs run in + // sequential segments, each segment sweeps all frequencies over its window + // while its Tx interleave per frequency; "interleaved" — legacy model where + // all Tx interleave over the whole frame. + std::string gpr_timing_mode = "segmented_interleaved"; + // Fallback segment size when per-trace capture timestamps are absent; + // timestamp grouping wins when available. + std::uint32_t gpr_tx_per_segment = 2U; + // Imaging plane of the 2D BP map (notebook BP_PLANE_MODE): "ground" — the + // plane follows the ground given the frame pitch, needed once the antennas + // are spread vertically; "const" — legacy fixed y = gpr_imaging_plane_y_m. + std::string gpr_plane_mode = "ground"; + // Height of the LOWER antenna row above the ground, metres (notebook + // BP_GROUND_DEPTH_M; structural constant of the frame). + float gpr_ground_depth_m = 1.25F; + // Frame pitch used by the ground plane. Override disabled -> the pitch + // falls back to gpr_look_angle_deg (notebook BP_GROUND_PITCH_DEG = None). + bool gpr_ground_pitch_override_enabled = false; + float gpr_ground_pitch_deg = 6.5F; // Coherent BP object visibility (window + the draw limits below) is applied in // the processor itself, matching Horns_motion_3libre.py — there is NO score // threshold for it. Only legacy GPR still thresholds, on a pair count. 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 2b236ca..a566241 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 @@ -67,6 +67,20 @@ using Json = nlohmann::json; throw std::runtime_error(field_name + " must be one of: int_minus, int_focus"); } +[[nodiscard]] auto parse_gpr_timing_mode(const std::string& value, const std::string& field_name) -> std::string { + if (value == "segmented_interleaved" || value == "interleaved") { + return value; + } + throw std::runtime_error(field_name + " must be one of: segmented_interleaved, interleaved"); +} + +[[nodiscard]] auto parse_gpr_plane_mode(const std::string& value, const std::string& field_name) -> std::string { + if (value == "ground" || value == "const") { + return value; + } + throw std::runtime_error(field_name + " must be one of: ground, const"); +} + void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::string& value) { if (value == "backprojection") { return; @@ -355,6 +369,45 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_imaging_plane_y_m = static_cast(found->get()); } + if (const auto found = root.find("gpr_timing_mode"); found != root.end()) { + if (!found->is_string()) { + throw std::runtime_error("processing.gpr_timing_mode must be string"); + } + config.gpr_timing_mode = parse_gpr_timing_mode(found->get(), "processing.gpr_timing_mode"); + } + if (const auto found = root.find("gpr_tx_per_segment"); found != root.end()) { + config.gpr_tx_per_segment = parse_u32_number(*found, "processing.gpr_tx_per_segment"); + if (config.gpr_tx_per_segment == 0U) { + throw std::runtime_error("processing.gpr_tx_per_segment must be >= 1"); + } + } + if (const auto found = root.find("gpr_plane_mode"); found != root.end()) { + if (!found->is_string()) { + throw std::runtime_error("processing.gpr_plane_mode must be string"); + } + config.gpr_plane_mode = parse_gpr_plane_mode(found->get(), "processing.gpr_plane_mode"); + } + if (const auto found = root.find("gpr_ground_depth_m"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_ground_depth_m must be number"); + } + config.gpr_ground_depth_m = static_cast(found->get()); + if (config.gpr_ground_depth_m < 0.0F) { + throw std::runtime_error("processing.gpr_ground_depth_m must be >= 0"); + } + } + if (const auto found = root.find("gpr_ground_pitch_override_enabled"); found != root.end()) { + if (!found->is_boolean()) { + throw std::runtime_error("processing.gpr_ground_pitch_override_enabled must be bool"); + } + config.gpr_ground_pitch_override_enabled = found->get(); + } + if (const auto found = root.find("gpr_ground_pitch_deg"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_ground_pitch_deg must be number"); + } + config.gpr_ground_pitch_deg = static_cast(found->get()); + } for (const auto& [key, target] : { std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m}, std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m}, 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 868f63f..43f4ae5 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 @@ -653,52 +653,242 @@ void validate_collection_trace_order( return traces; } -// Frequency-domain speed correction applied to S21 *before* the IFFT, ported -// from Horns_motion_3libre.py (apply_intra_sweep_phase_correction + -// compute_frequency_sample_times). The measurement is interleaved Tx-by-frequency -// — Tx0(f0), Tx1(f0), Tx0(f1), ... — so each Tx occupies one interleave slot and -// its frequency k is sampled at global step (slot_count*k + slot). Each in-band -// frequency's phase is rotated back to the centre of the (full) interleaved sweep. +// ── Frame acquisition timing ──────────────────────────────────────────────── // -// Unlike the offline Python script, every physical input is taken from the live -// system: `speed_mps` is the socket-fed velocity, `look_angle_deg`/`direction_sign` -// are config, and `tx_sweep_time_s` is derived per-collection from the acquisition -// timestamps (capture_span / Tx count). A zero speed or non-positive sweep time -// makes this a no-op. -void apply_intra_sweep_motion_correction( +// The real acquisition is SEGMENTED (notebook FREQUENCY_TIMING_MODE = +// 'segmented_interleaved'): the Tx outputs are grouped into segments that run +// one after another; each segment sweeps ALL frequencies over its own window, +// and within a segment its Tx interleave per frequency while all Rx receive in +// parallel. The dominant motion shift is therefore BETWEEN segments (a pair- +// level dtau relative to the frame centre), with a much smaller intra-segment +// sweep shift on top — the legacy whole-frame interleaved model captured only +// the latter. +// +// Segment windows come from the per-trace capture timestamps: traces measured +// in one segment share one (start, end) window on both producers, so grouping +// outputs by identical windows reconstructs the segments exactly (notebook +// snapshot_acquisition_timing). When timestamps are absent (zero sentinel) the +// frame is split uniformly into ceil(n_tx / tx_per_segment) segments. +// +// Segment/slot assignment is a hardware property: it is derived from the FULL +// run_combos order, never from the live Tx selection — otherwise disabling +// channels would silently shrink the frame model (this also fixes the old +// capture_span / N_selected_tx bug). + +struct SegmentWindow { + double start_s = 0.0; + double end_s = 0.0; + double center_s = 0.0; + bool stretched = false; +}; + +struct TxSlot { + std::size_t segment_index = 0U; + std::size_t slot = 0U; + std::size_t slot_count = 1U; +}; + +struct FrameTiming { + std::vector segments{}; + std::unordered_map by_output_pos{}; + double frame_center_s = 0.0; + bool from_trace_timestamps = false; +}; + +[[nodiscard]] auto build_frame_timing( + const config::RunConfig& run_config, + const ipc::PreprocessedCollection& collection, + std::size_t tx_per_segment_fallback, + bool segmented +) -> FrameTiming { + FrameTiming timing{}; + + // Full hardware Tx order: first occurrence of each output in run_combos + // (notebook tx_order_full). Deliberately ignores the live Tx selection. + std::vector tx_order{}; + for (const auto& combo : run_config.run_combos) { + if (std::find(tx_order.begin(), tx_order.end(), combo.output_pos) == tx_order.end()) { + tx_order.push_back(combo.output_pos); + } + } + if (tx_order.empty()) { + return timing; + } + + const double frame_span_s = + collection.capture_end_ns > collection.capture_start_ns + ? static_cast(collection.capture_end_ns - collection.capture_start_ns) * 1e-9 + : 0.0; + + // Timestamp path: per output take the window of its FIRST trace (notebook + // setdefault); require every window measured. Traces of one segment share + // one window by construction, so identical windows identify segments. + std::unordered_map> window_by_tx{}; + bool timestamps_ok = segmented; + if (segmented) { + for (const auto& trace : collection.traces) { + window_by_tx.try_emplace( + trace.combo.output_pos, + std::pair{trace.capture_start_ns, trace.capture_end_ns} + ); + } + for (const auto tx : tx_order) { + const auto found = window_by_tx.find(tx); + if (found == window_by_tx.end() || found->second.second <= found->second.first) { + timestamps_ok = false; + break; + } + } + } + + if (timestamps_ok) { + std::uint64_t first_start_ns = std::numeric_limits::max(); + std::uint64_t last_end_ns = 0U; + for (const auto tx : tx_order) { + const auto& window = window_by_tx.at(tx); + first_start_ns = std::min(first_start_ns, window.first); + last_end_ns = std::max(last_end_ns, window.second); + } + const double acquisition_s = static_cast(last_end_ns - first_start_ns) * 1e-9; + + // Unique windows ordered by start; Tx sharing a window form one segment, + // slotted in hardware order. + std::vector> windows{}; + for (const auto tx : tx_order) { + const auto& window = window_by_tx.at(tx); + if (std::find(windows.begin(), windows.end(), window) == windows.end()) { + windows.push_back(window); + } + } + std::sort(windows.begin(), windows.end()); + + const double nominal_segment_s = + acquisition_s / static_cast(std::max(windows.size(), 1U)); + timing.segments.reserve(windows.size()); + for (const auto& window : windows) { + SegmentWindow segment{}; + segment.start_s = static_cast(window.first - first_start_ns) * 1e-9; + segment.end_s = static_cast(window.second - first_start_ns) * 1e-9; + segment.center_s = 0.5 * (segment.start_s + segment.end_s); + // A segment noticeably longer than the frame/n_segments norm had an + // instrument stall inside; where exactly is unknowable from the + // timestamps, so the uniform frequency layout is approximate there. + segment.stretched = + nominal_segment_s > 0.0 && (segment.end_s - segment.start_s) > 1.03 * nominal_segment_s; + timing.segments.push_back(segment); + } + + for (const auto tx : tx_order) { + const auto& window = window_by_tx.at(tx); + const auto segment_index = static_cast( + std::find(windows.begin(), windows.end(), window) - windows.begin() + ); + TxSlot slot{}; + slot.segment_index = segment_index; + slot.slot = 0U; + for (const auto other : tx_order) { + if (other == tx) { + break; + } + if (window_by_tx.at(other) == window) { + ++slot.slot; + } + } + slot.slot_count = static_cast(std::count_if( + tx_order.begin(), tx_order.end(), + [&](std::uint32_t other) { return window_by_tx.at(other) == window; } + )); + timing.by_output_pos.emplace(tx, slot); + } + + timing.frame_center_s = 0.5 * acquisition_s; + timing.from_trace_timestamps = true; + return timing; + } + + // Fallback: uniform split of the collection-level span. `segmented` divides + // the outputs into chunks of tx_per_segment; the legacy 'interleaved' mode is + // the same code with a single segment holding every Tx. + const std::size_t per_segment = segmented ? std::max(tx_per_segment_fallback, 1U) + : tx_order.size(); + const std::size_t segment_count = (tx_order.size() + per_segment - 1U) / per_segment; + const double segment_span_s = frame_span_s / static_cast(segment_count); + timing.segments.reserve(segment_count); + for (std::size_t index = 0U; index < segment_count; ++index) { + SegmentWindow segment{}; + segment.start_s = static_cast(index) * segment_span_s; + segment.end_s = segment.start_s + segment_span_s; + segment.center_s = 0.5 * (segment.start_s + segment.end_s); + timing.segments.push_back(segment); + } + for (std::size_t index = 0U; index < tx_order.size(); ++index) { + TxSlot slot{}; + slot.segment_index = index / per_segment; + slot.slot = index % per_segment; + const std::size_t segment_begin = slot.segment_index * per_segment; + slot.slot_count = std::min(per_segment, tx_order.size() - segment_begin); + timing.by_output_pos.emplace(tx_order[index], slot); + } + timing.frame_center_s = 0.5 * frame_span_s; + return timing; +} + +// Frequency-domain speed correction applied to S21 *before* the IFFT, ported +// from the Multi_antenna_3D notebook (apply_intra_sweep_phase_correction + +// compute_frequency_sample_times, segmented branch). One phase model covers +// both motion terms: +// dtau_pair — the pair-level shift of this Tx's segment centre against the +// frame centre (dominant between sequential segments); +// dtau_intra — the within-segment sweep shift: frequency k of interleave +// slot `slot` is event (slot_count*k + slot) of the segment. +// The combined phi = 2*pi*f*(dtau_intra + dtau_pair) is applied as +// s21 *= exp(-i*phi), matching the notebook's motion_phase_sign = -1. +// +// Every physical input is taken from the live system: `speed_mps` is the +// socket-fed velocity, `look_angle_deg`/`direction_sign` are config, and the +// segment windows come from the collection's own trace timestamps. A zero +// speed or an empty segment window makes this a no-op. +void apply_motion_correction( SelectedTrace& trace, + const SegmentWindow& segment, std::size_t slot, std::size_t slot_count, + double frame_center_s, double start_hz, double stop_hz, double velocity_mps, double speed_mps, double look_angle_deg, double direction_sign, - double tx_sweep_time_s, MotionMode motion_mode ) { const std::size_t n_full = trace.frequency_hz.size(); + const double segment_span_s = segment.end_s - segment.start_s; if (n_full < 2U || trace.s21.size() != n_full || slot_count == 0U - || !(velocity_mps > 0.0) || !(tx_sweep_time_s > 0.0)) { + || !(velocity_mps > 0.0) || !(segment_span_s > 0.0)) { return; } const double low_hz = std::min(start_hz, stop_hz); const double high_hz = std::max(start_hz, stop_hz); - // Absolute sample time of full-sweep frequency index k for this Tx slot. - const double dt_base = tx_sweep_time_s / static_cast(n_full - 1U); + // The segment sweeps all frequencies over its window; on each frequency its + // Tx take turns, so frequency k of slot `slot` is event (slot_count*k + slot) + // out of slot_count*n_full. + const double dt_event_s = segment_span_s / (static_cast(slot_count) * static_cast(n_full)); const auto sample_time_s = [&](std::size_t index) { - return ((static_cast(slot_count) * static_cast(index)) + static_cast(slot)) * dt_base; + return segment.start_s + + ((static_cast(slot_count) * static_cast(index)) + static_cast(slot)) + * dt_event_s; }; - // t_center is the midpoint of the *full* interleaved sweep for this slot, - // i.e. between the first and last full-array frequencies (not the cut band). - const double t_center_s = 0.5 * (sample_time_s(0U) + sample_time_s(n_full - 1U)); const double theta_rad = (look_angle_deg * kPi) / 180.0; const double motion_factor = direction_sign * speed_mps * std::cos(theta_rad); + // Pair-level coarse shift: segment centre vs frame centre. + const double dz_pair_m = motion_factor * (segment.center_s - frame_center_s); + const double dtau_pair_s = 2.0 * dz_pair_m / velocity_mps; + std::vector band_indices{}; std::vector band_frequency_hz{}; std::vector phi{}; @@ -712,10 +902,9 @@ void apply_intra_sweep_motion_correction( if (frequency < low_hz || frequency > high_hz) { continue; } - const double dt_intra_s = sample_time_s(index) - t_center_s; - const double delta_range_m = motion_factor * dt_intra_s; - const double delta_path_m = 2.0 * delta_range_m; - const double dtau_s = delta_path_m / velocity_mps; + const double dt_intra_s = sample_time_s(index) - segment.center_s; + const double delta_path_m = 2.0 * motion_factor * dt_intra_s; + const double dtau_s = (delta_path_m / velocity_mps) + dtau_pair_s; if (dtau_s != 0.0) { any_nonzero = true; } @@ -892,11 +1081,32 @@ void normalize_pair_ascans( return std::sqrt((dx * dx) + (dy * dy) + (dz * dz)); } +// Y of the BP imaging plane at range z. In 'ground' mode the plane follows the +// ground in the radar's tilted axes (notebook bp_plane_y): the frame is pitched +// down by pitch_deg and the lower antenna row sits ground_depth_m above ground, +// so y(z) = (-ground_depth_m + z*sin(pitch)) / cos(pitch). With antennas spread +// vertically an off-plane point smears into an equal-range arc, so the plane +// choice matters; 'const' keeps the legacy fixed-y slice. +struct ImagingPlane { + bool ground_mode = false; + double const_y_m = 0.0; + double ground_depth_m = 0.0; + double pitch_deg = 0.0; + + [[nodiscard]] auto y_at(double z_m) const -> double { + if (!ground_mode) { + return const_y_m; + } + const double pitch_rad = (pitch_deg * kPi) / 180.0; + return (-ground_depth_m + (z_m * std::sin(pitch_rad))) / std::cos(pitch_rad); + } +}; + [[nodiscard]] auto build_grid( const GeometrySelection& selection, double max_depth_m, double min_z_m, - double imaging_plane_y_m + const ImagingPlane& plane ) -> GridDefinition { GridDefinition grid{}; if (selection.x_tx.empty() || selection.x_rx.empty() || !(max_depth_m > min_z_m)) { @@ -917,6 +1127,8 @@ void normalize_pair_ascans( for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { const double z_value = grid.z_grid[row]; + // Plane height depends only on range, so it is constant along a row. + const double plane_y_m = plane.y_at(z_value); for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { const double x_value = grid.x_grid[col]; const auto cell_index = (row * grid.x_grid.size()) + col; @@ -924,14 +1136,14 @@ void normalize_pair_ascans( for (std::size_t tx_index = 0U; tx_index < selection.x_tx.size(); ++tx_index) { grid.tx_distance_grids[tx_index][cell_index] = distance_3d( x_value - selection.x_tx[tx_index], - imaging_plane_y_m - selection.y_tx[tx_index], + plane_y_m - selection.y_tx[tx_index], z_value - selection.z_tx[tx_index] ); } for (std::size_t rx_index = 0U; rx_index < selection.x_rx.size(); ++rx_index) { grid.rx_distance_grids[rx_index][cell_index] = distance_3d( x_value - selection.x_rx[rx_index], - imaging_plane_y_m - selection.y_rx[rx_index], + plane_y_m - selection.y_rx[rx_index], z_value - selection.z_rx[rx_index] ); } @@ -948,10 +1160,10 @@ void normalize_pair_ascans( const GeometrySelection& selection, double max_depth_m, double min_z_m, - double imaging_plane_y_m + const ImagingPlane& plane ) -> std::vector { std::vector signature; - signature.reserve((selection.x_tx.size() + selection.x_rx.size()) * 3U + 3U); + signature.reserve((selection.x_tx.size() + selection.x_rx.size()) * 3U + 6U); const auto append = [&](const std::vector& axis) { signature.insert(signature.end(), axis.begin(), axis.end()); }; @@ -963,7 +1175,10 @@ void normalize_pair_ascans( append(selection.z_rx); signature.push_back(max_depth_m); signature.push_back(min_z_m); - signature.push_back(imaging_plane_y_m); + signature.push_back(plane.ground_mode ? 1.0 : 0.0); + signature.push_back(plane.const_y_m); + signature.push_back(plane.ground_depth_m); + signature.push_back(plane.pitch_deg); return signature; } @@ -1003,19 +1218,20 @@ void normalize_pair_ascans( std::uint32_t tx_index, std::uint32_t rx_index, const GeometrySelection& selection, - double imaging_plane_y_m + const ImagingPlane& plane ) -> std::pair { const double x_center = 0.5 * (selection.x_tx[tx_index] + selection.x_rx[rx_index]); + const double plane_y_m = plane.y_at(kCompensationReferenceDepthM); const double dz_tx = kCompensationReferenceDepthM - selection.z_tx[tx_index]; const double dz_rx = kCompensationReferenceDepthM - selection.z_rx[rx_index]; const double r_tx = distance_3d( x_center - selection.x_tx[tx_index], - imaging_plane_y_m - selection.y_tx[tx_index], + plane_y_m - selection.y_tx[tx_index], dz_tx ); const double r_rx = distance_3d( x_center - selection.x_rx[rx_index], - imaging_plane_y_m - selection.y_rx[rx_index], + plane_y_m - selection.y_rx[rx_index], dz_rx ); return attenuation_components(r_tx, r_rx, dz_tx, dz_rx); @@ -1053,7 +1269,7 @@ void normalize_pair_ascans( const std::unordered_map& ascans_by_pair, const GridDefinition& grid, const GeometrySelection& selection, - double imaging_plane_y_m, + const ImagingPlane& plane, double velocity_mps, double min_depth_m, double max_depth_m, @@ -1095,7 +1311,7 @@ void normalize_pair_ascans( trace.tx_local_index, trace.rx_local_index, selection, - imaging_plane_y_m + plane ); contributions.push_back(PairContribution{ &grid.tx_distance_grids[trace.tx_local_index], @@ -1505,21 +1721,22 @@ void apply_depth_gate( const ObjectRecord& object, const std::vector& selected_traces, const GeometrySelection& selection, - double imaging_plane_y_m + const ImagingPlane& plane ) -> std::vector { std::vector signature{}; signature.reserve(selected_traces.size()); + const double plane_y_m = plane.y_at(object.z_m); for (const auto& trace : selected_traces) { const auto tx = trace.tx_local_index; const auto rx = trace.rx_local_index; const double r_tx = distance_3d( object.x_m - selection.x_tx[tx], - imaging_plane_y_m - selection.y_tx[tx], + plane_y_m - selection.y_tx[tx], object.z_m - selection.z_tx[tx] ); const double r_rx = distance_3d( object.x_m - selection.x_rx[rx], - imaging_plane_y_m - selection.y_rx[rx], + plane_y_m - selection.y_rx[rx], object.z_m - selection.z_rx[rx] ); signature.push_back(0.5 * (r_tx + r_rx)); @@ -1531,12 +1748,12 @@ void mark_sidelobe_candidates( std::vector& objects, const std::vector& selected_traces, const GeometrySelection& selection, - double imaging_plane_y_m + const ImagingPlane& plane ) { std::vector> signatures{}; signatures.reserve(objects.size()); for (const auto& object : objects) { - signatures.push_back(bistatic_depth_signature(object, selected_traces, selection, imaging_plane_y_m)); + signatures.push_back(bistatic_depth_signature(object, selected_traces, selection, plane)); } for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) { @@ -1871,34 +2088,48 @@ void add_bp_score_metrics( return results; } - // Intra-sweep speed correction before the IFFT. The Tx interleave slot is the - // Tx local index (outputs are sorted), with slot_count = number of selected Tx. - // tx_sweep_time is derived per-collection from the acquisition timestamps: - // one Tx's interleaved sweep spans the whole frame, so capture_span / N_tx. + // Motion correction before the IFFT: pair-level (between segments) plus + // intra-segment sweep shift, one phase model (see apply_motion_correction). + // The frame timing is built from the FULL collection — including traces of + // deselected Tx — so disabling channels never shrinks the frame model. // Speed comes from the socket-fed live config; look-angle/direction from config. const auto motion_mode = parse_motion_mode(live_config.gpr_motion_mode); - const std::size_t motion_slot_count = selection.output_positions.size(); - const double capture_span_s = - collection.capture_end_ns > collection.capture_start_ns - ? static_cast(collection.capture_end_ns - collection.capture_start_ns) * 1e-9 - : 0.0; - const double tx_sweep_time_s = - motion_slot_count > 0U ? capture_span_s / static_cast(motion_slot_count) : 0.0; + const auto frame_timing = build_frame_timing( + run_config, + collection, + static_cast(std::max(live_config.gpr_tx_per_segment, 1U)), + live_config.gpr_timing_mode == "segmented_interleaved" + ); + for (const auto& segment : frame_timing.segments) { + if (segment.stretched) { + std::cerr << "[gpr] collection " << collection.collection_id + << ": segment at " << segment.start_s << "s runs " + << (segment.end_s - segment.start_s) + << "s (above the frame/n_segments norm) — the instrument stalled " + << "inside it, motion correction for this frame is approximate\n"; + } + } const double motion_speed_mps = static_cast(live_config.gpr_speed_m_s); const double motion_look_angle_deg = static_cast(live_config.gpr_look_angle_deg); const double motion_direction_sign = static_cast(live_config.gpr_direction_sign); for (auto& trace : selected_traces) { - apply_intra_sweep_motion_correction( + const auto slot_it = frame_timing.by_output_pos.find(trace.combo.output_pos); + if (slot_it == frame_timing.by_output_pos.end()) { + continue; + } + const auto& tx_slot = slot_it->second; + apply_motion_correction( trace, - trace.tx_local_index, - motion_slot_count, + frame_timing.segments[tx_slot.segment_index], + tx_slot.slot, + tx_slot.slot_count, + frame_timing.frame_center_s, start_hz, stop_hz, velocity_mps, motion_speed_mps, motion_look_angle_deg, motion_direction_sign, - tx_sweep_time_s, motion_mode ); } @@ -1919,18 +2150,28 @@ void add_bp_score_metrics( } normalize_pair_ascans(ascans_by_pair, velocity_mps, min_depth_m, max_depth_m); - const double imaging_plane_y_m = static_cast(live_config.gpr_imaging_plane_y_m); + // 'ground' plane pitch falls back to the look angle when the override is + // off (notebook BP_GROUND_PITCH_DEG = None -> LOOK_ANGLE_DEG). + const ImagingPlane plane{ + live_config.gpr_plane_mode == "ground", + static_cast(live_config.gpr_imaging_plane_y_m), + static_cast(live_config.gpr_ground_depth_m), + live_config.gpr_ground_pitch_override_enabled + ? static_cast(live_config.gpr_ground_pitch_deg) + : static_cast(live_config.gpr_look_angle_deg), + }; // The imaging grid (90k cells x 6 distance fields) only depends on antenna - // geometry and the depth window, which are constant across a run, so it is - // memoized between collections and rebuilt only when that signature changes. - // process_backprojection_gpr is entered by a single processing thread (the - // backprojection workers join before it returns), so a local static is safe. + // geometry, the depth window and the imaging plane, which are constant + // across a run, so it is memoized between collections and rebuilt only when + // that signature changes. process_backprojection_gpr is entered by a single + // processing thread (the backprojection workers join before it returns), so + // a local static is safe. static std::vector cached_grid_signature; static GridDefinition cached_grid; - const auto signature = grid_signature(selection, max_depth_m, kGridZMinM, imaging_plane_y_m); + const auto signature = grid_signature(selection, max_depth_m, kGridZMinM, plane); if (cached_grid.x_grid.empty() || signature != cached_grid_signature) { - cached_grid = build_grid(selection, max_depth_m, kGridZMinM, imaging_plane_y_m); + cached_grid = build_grid(selection, max_depth_m, kGridZMinM, plane); cached_grid_signature = signature; } const GridDefinition& grid = cached_grid; @@ -1943,7 +2184,7 @@ void add_bp_score_metrics( ascans_by_pair, grid, selection, - imaging_plane_y_m, + plane, velocity_mps, min_depth_m, max_depth_m, @@ -1961,7 +2202,7 @@ void add_bp_score_metrics( auto objects = find_bp_objects(display_map, grid, static_cast(live_config.gpr_object_min_frac)); add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m); add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor); - mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m); + mark_sidelobe_candidates(objects, selected_traces, selection, plane); add_bp_score_metrics(objects, live_config); if (live_config.gpr_remove_sidelobe_objects_enabled) { diff --git a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp index aa5b386..94a1972 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp +++ b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp @@ -918,7 +918,12 @@ void apply_legacy_motion_correction( return results; } - const auto grid = build_grid(selection, max_depth_m, kLegacyGridZMinM, imaging_plane_y_m); + // Legacy GPR always images in the fixed-y slice; the ground plane mode is a + // coherent-BP-only feature. + const auto grid = build_grid( + selection, max_depth_m, kLegacyGridZMinM, + ImagingPlane{false, imaging_plane_y_m, 0.0, 0.0} + ); if (grid.x_grid.empty() || grid.z_grid.empty()) { return results; } diff --git a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp index 43172ce..64f8f91 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include diff --git a/data_acq_and_processing/processing/tools/gpr_timing_check_main.cpp b/data_acq_and_processing/processing/tools/gpr_timing_check_main.cpp new file mode 100644 index 0000000..6ff498d --- /dev/null +++ b/data_acq_and_processing/processing/tools/gpr_timing_check_main.cpp @@ -0,0 +1,170 @@ +// Numeric check of the GPR backprojection frame-timing model against the +// Multi_antenna_3D notebook (compute_pair_timestamps_for_snapshot + +// apply_intra_sweep_phase_correction, 'segmented_interleaved'). +// +// Builds a synthetic 4x4 collection with two segment windows taken from +// per-trace capture timestamps, runs build_frame_timing and +// apply_motion_correction from gpr_backprojection_processor.ipp, and prints +// segment/slot assignment, frame centre, per-Tx pair dtau and the motion phase +// at probe frequency indices. Compare against the Python replica +// (scripts/gpr_timing_check_reference.py) — every printed number must match. +// +// Also prints the fallback (zero timestamps) and legacy 'interleaved' layouts. +// +// Usage: gpr_timing_check +#include "gpr_processor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace radar::processing { +namespace { + +#include "../processors/src/gpr_backprojection_processor.ipp" + +constexpr double kSpeedMps = 1.42; +constexpr double kLookAngleDeg = 19.5; +constexpr double kDirectionSign = 1.0; +constexpr std::size_t kFreqCount = 11U; + +[[nodiscard]] auto make_run_config() -> config::RunConfig { + config::RunConfig run_config{}; + for (std::uint32_t output = 0U; output < 4U; ++output) { + for (std::uint32_t input = 0U; input < 4U; ++input) { + run_config.run_combos.push_back(ipc::ComboKey{input, output}); + } + } + return run_config; +} + +[[nodiscard]] auto make_collection(bool with_trace_timestamps) -> ipc::PreprocessedCollection { + ipc::PreprocessedCollection collection{}; + collection.collection_id = 7U; + // The collection-level capture starts 10 ms before the first trace — the + // trace marks, not the collection window, must define the frame model. + collection.capture_start_ns = 990'000'000ULL; + collection.capture_end_ns = 1'483'000'000ULL; + + // Segment A (Tx0, Tx1): 1.000..1.223 s; segment B (Tx2, Tx3): 1.223..1.483 s + // (260 ms against the 241.5 ms norm -> stretched). + const auto window_for_tx = [&](std::uint32_t output) -> std::pair { + if (!with_trace_timestamps) { + return {0U, 0U}; + } + return output < 2U + ? std::pair{1'000'000'000ULL, 1'223'000'000ULL} + : std::pair{1'223'000'000ULL, 1'483'000'000ULL}; + }; + + for (std::uint32_t output = 0U; output < 4U; ++output) { + for (std::uint32_t input = 0U; input < 4U; ++input) { + ipc::SweepTraceBlock trace{}; + trace.combo = ipc::ComboKey{input, output}; + const auto window = window_for_tx(output); + trace.capture_start_ns = window.first; + trace.capture_end_ns = window.second; + collection.traces.push_back(std::move(trace)); + } + } + return collection; +} + +[[nodiscard]] auto make_trace(std::uint32_t output_pos) -> SelectedTrace { + SelectedTrace trace{}; + trace.combo = ipc::ComboKey{0U, output_pos}; + trace.frequency_hz.resize(kFreqCount); + trace.s21.assign(kFreqCount, std::complex(1.0, 0.0)); + for (std::size_t index = 0U; index < kFreqCount; ++index) { + trace.frequency_hz[index] = + 1.5e9 + (static_cast(index) * (6.0e9 - 1.5e9) / static_cast(kFreqCount - 1U)); + } + return trace; +} + +void print_timing(const char* label, const FrameTiming& timing) { + std::printf("%s: frame_center_s=%.9f from_timestamps=%d\n", + label, timing.frame_center_s, timing.from_trace_timestamps ? 1 : 0); + for (std::size_t index = 0U; index < timing.segments.size(); ++index) { + const auto& segment = timing.segments[index]; + std::printf(" segment %zu: start=%.9f end=%.9f center=%.9f stretched=%d\n", + index, segment.start_s, segment.end_s, segment.center_s, + segment.stretched ? 1 : 0); + } + std::vector outputs{}; + outputs.reserve(timing.by_output_pos.size()); + for (const auto& [output, _slot] : timing.by_output_pos) { + outputs.push_back(output); + } + std::sort(outputs.begin(), outputs.end()); + for (const auto output : outputs) { + const auto& slot = timing.by_output_pos.at(output); + std::printf(" tx o%u: segment=%zu slot=%zu slot_count=%zu\n", + output, slot.segment_index, slot.slot, slot.slot_count); + } +} + +void print_motion_phase(const FrameTiming& timing, MotionMode motion_mode, const char* label) { + for (std::uint32_t output = 0U; output < 4U; ++output) { + auto trace = make_trace(output); + const auto& slot = timing.by_output_pos.at(output); + apply_motion_correction( + trace, + timing.segments[slot.segment_index], + slot.slot, + slot.slot_count, + timing.frame_center_s, + 1.5e9, + 6.0e9, + kSpeedOfLightMetersPerSec, + kSpeedMps, + kLookAngleDeg, + kDirectionSign, + motion_mode + ); + // s21 started at 1+0j, so arg(s21[k]) = -phi[k] (notebook sign = -1). + std::printf("%s tx o%u: phi(k=0)=%.12e phi(k=5)=%.12e phi(k=10)=%.12e\n", + label, output, + -std::arg(trace.s21[0]), -std::arg(trace.s21[5]), -std::arg(trace.s21[10])); + } +} + +[[nodiscard]] auto run_check() -> int { + const auto run_config = make_run_config(); + + const auto timed = build_frame_timing(run_config, make_collection(true), 2U, true); + print_timing("segmented+timestamps", timed); + print_motion_phase(timed, MotionMode::IntMinus, "int_minus"); + print_motion_phase(timed, MotionMode::IntFocus, "int_focus"); + + const auto fallback = build_frame_timing(run_config, make_collection(false), 2U, true); + print_timing("segmented+fallback", fallback); + print_motion_phase(fallback, MotionMode::IntMinus, "fallback int_minus"); + + const auto interleaved = build_frame_timing(run_config, make_collection(true), 2U, false); + print_timing("interleaved", interleaved); + + return 0; +} + +} // namespace +} // namespace radar::processing + +auto main() -> int { + return radar::processing::run_check(); +} 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 0b410c3..10f93bf 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 @@ -64,6 +64,10 @@ _WEB_LIVE_SCHEMA = [ ("gpr_start_freq_mhz", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_start_freq_mhz", {"legacy_gpr": "_legacy_gpr_start_freq_mhz", "sar3d": "_sar3d_start_freq_mhz"})), ("gpr_stop_freq_mhz", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_stop_freq_mhz", {"legacy_gpr": "_legacy_gpr_stop_freq_mhz", "sar3d": "_sar3d_stop_freq_mhz"})), ("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")), + ("gpr_plane_mode", "Geometry & depth", ("gpr",), _attr("_gpr_plane_mode")), + ("gpr_ground_depth_m", "Geometry & depth", ("gpr",), _attr("_gpr_ground_depth_m")), + ("gpr_ground_pitch_override_enabled", "Geometry & depth", ("gpr",), _attr("_gpr_ground_pitch_override_enabled")), + ("gpr_ground_pitch_deg", "Geometry & depth", ("gpr",), _attr("_gpr_ground_pitch_deg")), ("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")), ("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")), ("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", {"legacy_gpr": "_legacy_gpr_background_subtract_enabled"})), @@ -82,6 +86,8 @@ _WEB_LIVE_SCHEMA = [ ("gpr_visible_z_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_min_m", {"legacy_gpr": "_legacy_gpr_visible_z_min_m"})), ("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", {"legacy_gpr": "_legacy_gpr_visible_z_max_m"})), ("gpr_motion_mode", "Motion", ("gpr",), _attr("_gpr_motion_mode")), + ("gpr_timing_mode", "Motion", ("gpr",), _attr("_gpr_timing_mode")), + ("gpr_tx_per_segment", "Motion", ("gpr",), _attr("_gpr_tx_per_segment")), ("gpr_look_angle_deg", "Motion", _GPR_LIKE_MODES, _dual("_gpr_look_angle_deg", {"legacy_gpr": "_legacy_gpr_look_angle_deg", "sar3d": "_sar3d_look_angle_deg"})), ("gpr_direction_sign", "Motion", ("gpr", "sar3d"), _dual("_gpr_direction_sign", {"sar3d": "_sar3d_direction_sign"})), ("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr", "sar3d"), _dual("_legacy_gpr_apply_freq_phase_correction", {"sar3d": "_sar3d_apply_freq_phase_correction"})), 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 8fbd169..e457402 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 @@ -309,6 +309,12 @@ class AppWindowConfigProfileIOMixin: self._gpr_background_mean_count, self._gpr_remove_sidelobe_objects_enabled, self._gpr_imaging_plane_y_m, + self._gpr_timing_mode, + self._gpr_tx_per_segment, + self._gpr_plane_mode, + self._gpr_ground_depth_m, + self._gpr_ground_pitch_override_enabled, + self._gpr_ground_pitch_deg, self._gpr_render_mode, self._gpr_visible_x_min_m, self._gpr_visible_x_max_m, @@ -507,6 +513,16 @@ class AppWindowConfigProfileIOMixin: bool(gui_state.processing.gpr.remove_sidelobe_objects_enabled) ) self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m)) + self._set_combo_current_text(self._gpr_timing_mode, gui_state.processing.gpr.timing_mode) + self._gpr_tx_per_segment.setValue(int(gui_state.processing.gpr.tx_per_segment)) + self._set_combo_current_text(self._gpr_plane_mode, gui_state.processing.gpr.plane_mode) + self._gpr_ground_depth_m.setValue(float(gui_state.processing.gpr.ground_depth_m)) + ground_pitch_override_enabled = bool( + gui_state.processing.gpr.ground_pitch_override_enabled + ) + self._gpr_ground_pitch_override_enabled.setChecked(ground_pitch_override_enabled) + self._gpr_ground_pitch_deg.setValue(float(gui_state.processing.gpr.ground_pitch_deg)) + self._gpr_ground_pitch_deg.setEnabled(ground_pitch_override_enabled) self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode) self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m)) self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m)) 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 e2b8b09..beb1d88 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -391,6 +391,12 @@ class AppWindowConfigStateBuildersMixin: background_mean_count=int(self._gpr_background_mean_count.value()), remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()), + timing_mode=self._gpr_timing_mode.currentText(), + tx_per_segment=int(self._gpr_tx_per_segment.value()), + plane_mode=self._gpr_plane_mode.currentText(), + ground_depth_m=float(self._gpr_ground_depth_m.value()), + ground_pitch_override_enabled=bool(self._gpr_ground_pitch_override_enabled.isChecked()), + ground_pitch_deg=float(self._gpr_ground_pitch_deg.value()), render_mode=self._gpr_render_mode.currentText(), visible_x_min_m=float(self._gpr_visible_x_min_m.value()), visible_x_max_m=float(self._gpr_visible_x_max_m.value()), diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 161d59a..8683bb0 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -376,9 +376,58 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_imaging_plane_y_m.setSingleStep(0.05) owner._gpr_imaging_plane_y_m.setValue(float(gpr_live_defaults.imaging_plane_y_m)) owner._gpr_imaging_plane_y_m.setToolTip( - "Y coordinate of the BP imaging slice (m). Use 0 for legacy 1D antenna layouts." + "Y coordinate of the BP imaging slice (m) in 'const' plane mode. " + "Use 0 for legacy 1D antenna layouts." ) + owner._gpr_timing_mode = QComboBox() + owner._gpr_timing_mode.addItems(["segmented_interleaved", "interleaved"]) + owner._set_combo_current_text(owner._gpr_timing_mode, gpr_live_defaults.timing_mode) + owner._gpr_timing_mode.setToolTip( + "Acquisition timing model for motion correction: 'segmented_interleaved' — Tx run " + "in sequential segments (real hardware), 'interleaved' — legacy whole-frame model." + ) + + owner._gpr_tx_per_segment = QSpinBox() + owner._gpr_tx_per_segment.setRange(1, 64) + owner._gpr_tx_per_segment.setValue(int(gpr_live_defaults.tx_per_segment)) + owner._gpr_tx_per_segment.setToolTip( + "Fallback Tx count per segment when per-trace capture timestamps are absent; " + "timestamp grouping wins when available." + ) + + owner._gpr_plane_mode = QComboBox() + owner._gpr_plane_mode.addItems(["ground", "const"]) + owner._set_combo_current_text(owner._gpr_plane_mode, gpr_live_defaults.plane_mode) + owner._gpr_plane_mode.setToolTip( + "BP imaging plane: 'ground' follows the ground given the frame pitch " + "(needed with vertically spread antennas), 'const' is the legacy fixed Y." + ) + + owner._gpr_ground_depth_m = QDoubleSpinBox() + owner._gpr_ground_depth_m.setDecimals(3) + owner._gpr_ground_depth_m.setRange(0.0, 50.0) + owner._gpr_ground_depth_m.setSingleStep(0.05) + owner._gpr_ground_depth_m.setValue(float(gpr_live_defaults.ground_depth_m)) + owner._gpr_ground_depth_m.setToolTip( + "Height of the lower antenna row above the ground (m); structural constant." + ) + + owner._gpr_ground_pitch_override_enabled = QCheckBox("Override ground pitch") + owner._gpr_ground_pitch_override_enabled.setChecked( + bool(gpr_live_defaults.ground_pitch_override_enabled) + ) + owner._gpr_ground_pitch_override_enabled.setToolTip( + "Unchecked: the ground plane uses 'Look angle deg' as the frame pitch." + ) + + owner._gpr_ground_pitch_deg = QDoubleSpinBox() + owner._gpr_ground_pitch_deg.setDecimals(2) + owner._gpr_ground_pitch_deg.setRange(-90.0, 90.0) + owner._gpr_ground_pitch_deg.setSingleStep(0.1) + owner._gpr_ground_pitch_deg.setValue(float(gpr_live_defaults.ground_pitch_deg)) + owner._gpr_ground_pitch_deg.setEnabled(bool(gpr_live_defaults.ground_pitch_override_enabled)) + gpr_page = _build_processing_mode_page( owner._processing_mode_pages, [ @@ -400,6 +449,12 @@ def build_processing_group(owner) -> QGroupBox: ("Approach min frames", owner._gpr_object_approach_min_frames), ("Start MHz", owner._gpr_start_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz), + ("Timing mode", owner._gpr_timing_mode), + ("Tx per segment", owner._gpr_tx_per_segment), + ("Plane mode", owner._gpr_plane_mode), + ("Ground depth m", owner._gpr_ground_depth_m), + owner._gpr_ground_pitch_override_enabled, + ("Ground pitch deg", owner._gpr_ground_pitch_deg), ("Imaging plane Y m", owner._gpr_imaging_plane_y_m), ("Visible X min m", owner._gpr_visible_x_min_m), ("Visible X max m", owner._gpr_visible_x_max_m), @@ -793,6 +848,13 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_timing_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_tx_per_segment.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_plane_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_ground_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_ground_pitch_override_enabled.toggled.connect(owner._gpr_ground_pitch_deg.setEnabled) + owner._gpr_ground_pitch_override_enabled.toggled.connect(owner._on_processing_live_settings_changed) + owner._gpr_ground_pitch_deg.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_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) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index 2e73211..6c95b84 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -380,6 +380,42 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.imaging_plane_y_m, "gui.processing.gpr", ), + timing_mode=_optional_string( + gpr_object, + "timing_mode", + gui.processing.gpr.timing_mode, + "gui.processing.gpr", + ), + tx_per_segment=_optional_int( + gpr_object, + "tx_per_segment", + gui.processing.gpr.tx_per_segment, + "gui.processing.gpr", + ), + plane_mode=_optional_string( + gpr_object, + "plane_mode", + gui.processing.gpr.plane_mode, + "gui.processing.gpr", + ), + ground_depth_m=_optional_float( + gpr_object, + "ground_depth_m", + gui.processing.gpr.ground_depth_m, + "gui.processing.gpr", + ), + ground_pitch_override_enabled=_optional_bool( + gpr_object, + "ground_pitch_override_enabled", + gui.processing.gpr.ground_pitch_override_enabled, + "gui.processing.gpr", + ), + ground_pitch_deg=_optional_float( + gpr_object, + "ground_pitch_deg", + gui.processing.gpr.ground_pitch_deg, + "gui.processing.gpr", + ), render_mode=_optional_string( gpr_object, "render_mode", @@ -598,6 +634,16 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: raise ValueError("gui.processing.gpr.score_mode must be one of: peak, combined") if gui.processing.gpr.motion_mode not in {"int_minus", "int_focus"}: raise ValueError("gui.processing.gpr.motion_mode must be one of: int_minus, int_focus") + if gui.processing.gpr.timing_mode not in {"segmented_interleaved", "interleaved"}: + raise ValueError( + "gui.processing.gpr.timing_mode must be one of: segmented_interleaved, interleaved" + ) + if gui.processing.gpr.tx_per_segment < 1: + raise ValueError("gui.processing.gpr.tx_per_segment must be >= 1") + if gui.processing.gpr.plane_mode not in {"ground", "const"}: + raise ValueError("gui.processing.gpr.plane_mode must be one of: ground, const") + if gui.processing.gpr.ground_depth_m < 0.0: + raise ValueError("gui.processing.gpr.ground_depth_m must be >= 0") 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"}: @@ -750,6 +796,12 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "background_mean_count": gui.processing.gpr.background_mean_count, "remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled, "imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m, + "timing_mode": gui.processing.gpr.timing_mode, + "tx_per_segment": gui.processing.gpr.tx_per_segment, + "plane_mode": gui.processing.gpr.plane_mode, + "ground_depth_m": gui.processing.gpr.ground_depth_m, + "ground_pitch_override_enabled": gui.processing.gpr.ground_pitch_override_enabled, + "ground_pitch_deg": gui.processing.gpr.ground_pitch_deg, "render_mode": gui.processing.gpr.render_mode, "visible_x_min_m": gui.processing.gpr.visible_x_min_m, "visible_x_max_m": gui.processing.gpr.visible_x_max_m, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 70d0036..6ac21e6 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -87,6 +87,18 @@ class GuiGprStateModel: background_mean_count: int = 10 remove_sidelobe_objects_enabled: bool = True imaging_plane_y_m: float = 0.0 + # Acquisition timing model for motion correction: "segmented_interleaved" + # (real hardware: Tx segments run sequentially) or "interleaved" (legacy: + # all Tx interleave over the whole frame). + timing_mode: str = "segmented_interleaved" + # Fallback segment size when per-trace capture timestamps are absent. + tx_per_segment: int = 2 + # Imaging plane: "ground" follows the ground given the frame pitch; + # "const" is the legacy fixed y = imaging_plane_y_m. + plane_mode: str = "ground" + ground_depth_m: float = 1.25 + ground_pitch_override_enabled: bool = False + ground_pitch_deg: float = 6.5 render_mode: str = "heatmap" visible_x_min_m: float = -2.0 visible_x_max_m: float = 2.0 diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 1b84cf0..fe2549a 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -62,6 +62,25 @@ class ProcessingLiveConfig: gpr_background_mean_count: int = 10 gpr_remove_sidelobe_objects_enabled: bool = True gpr_imaging_plane_y_m: float = 0.0 + # Acquisition timing model for backprojection motion correction: + # "segmented_interleaved" — Tx outputs run in sequential segments, each + # segment sweeps all frequencies over its window while its Tx interleave + # per frequency (real hardware scheme, notebook FREQUENCY_TIMING_MODE); + # "interleaved" — legacy model, all Tx interleave over the whole frame. + gpr_timing_mode: str = "segmented_interleaved" + # Fallback segment size when per-trace capture timestamps are absent; + # timestamp grouping wins when available. + gpr_tx_per_segment: int = 2 + # Imaging plane for the 2D BP map: "ground" — plane follows the ground + # given the frame pitch (notebook BP_PLANE_MODE='ground'), "const" — + # legacy fixed y = gpr_imaging_plane_y_m. + gpr_plane_mode: str = "ground" + # Height of the LOWER antenna row above the ground, metres (structural + # constant, notebook BP_GROUND_DEPTH_M). + gpr_ground_depth_m: float = 1.25 + # Frame pitch used by the ground plane. Disabled -> gpr_look_angle_deg. + gpr_ground_pitch_override_enabled: bool = False + gpr_ground_pitch_deg: float = 6.5 # Locator filter parameter consumed by the C++ TCP locator server. Coherent BP # objects are already finalized in the processor (no score threshold); only legacy # GPR still thresholds, on a pair count. @@ -149,6 +168,12 @@ class ProcessingLiveConfig: "gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), + "gpr_timing_mode": str(self.gpr_timing_mode), + "gpr_tx_per_segment": int(self.gpr_tx_per_segment), + "gpr_plane_mode": str(self.gpr_plane_mode), + "gpr_ground_depth_m": float(self.gpr_ground_depth_m), + "gpr_ground_pitch_override_enabled": bool(self.gpr_ground_pitch_override_enabled), + "gpr_ground_pitch_deg": float(self.gpr_ground_pitch_deg), "legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count), "gpr_visible_x_min_m": float(self.gpr_visible_x_min_m), "gpr_visible_x_max_m": float(self.gpr_visible_x_max_m), diff --git a/python_app/scripts/gpr_timing_check_reference.py b/python_app/scripts/gpr_timing_check_reference.py new file mode 100644 index 0000000..e86b77e --- /dev/null +++ b/python_app/scripts/gpr_timing_check_reference.py @@ -0,0 +1,101 @@ +"""Notebook-formula reference for the C++ `gpr_timing_check` tool. + +Recomputes the same synthetic 4x4 scenario with the Multi_antenna_3D notebook +formulas (compute_pair_timestamps_for_snapshot + compute_frequency_sample_times ++ apply_intra_sweep_phase_correction, 'segmented_interleaved' branch) and +prints the motion phase at the same probe indices in the same format. Run: + + make gpr_timing_check && build/bin/gpr_timing_check > /tmp/cpp.txt + python python_app/scripts/gpr_timing_check_reference.py > /tmp/ref.txt + +The `int_minus` / `int_focus` lines of both outputs must match to ~1e-9 rad +(phases are compared wrapped to (-pi, pi]). +""" + +from __future__ import annotations + +import numpy as np + +SPEED_OF_LIGHT = 299_792_458.0 # C++ kSpeedOfLightMetersPerSec +SPEED_M_S = 1.42 +LOOK_ANGLE_DEG = 19.5 +DIRECTION_SIGN = 1.0 +F_START = 1.5e9 +F_STOP = 6.0e9 +N_FREQ = 11 + +# Trace windows of the synthetic collection (absolute seconds): segment A is +# Tx0/Tx1, segment B is Tx2/Tx3 (260 ms, stretched). +WINDOWS = {0: (1.000, 1.223), 1: (1.000, 1.223), 2: (1.223, 1.483), 3: (1.223, 1.483)} +TX_ORDER = [0, 1, 2, 3] +PROBE_INDICES = (0, 5, 10) + + +def wrap(phase: np.ndarray) -> np.ndarray: + """Wrap phases to (-pi, pi] the way -arg(exp(-1j*phi)) reports them.""" + return -np.angle(np.exp(-1j * phase)) + + +def main() -> None: + freqs = np.linspace(F_START, F_STOP, N_FREQ) + first = min(start for start, _ in WINDOWS.values()) + last = max(end for _, end in WINDOWS.values()) + acq_s = last - first + t_ref = 0.5 * acq_s + + # Group Tx with identical windows into segments (snapshot_acquisition_timing + # + compute_pair_timestamps_for_snapshot). + groups: dict[tuple[float, float], list[int]] = {} + for tx in TX_ORDER: + groups.setdefault(WINDOWS[tx], []).append(tx) + seg_of = {} + for window in sorted(groups): + for slot, tx in enumerate(groups[window]): + seg_of[tx] = (window, slot, len(groups[window])) + + cos_theta = np.cos(np.radians(LOOK_ANGLE_DEG)) + motion_factor = DIRECTION_SIGN * SPEED_M_S * cos_theta + + print(f"reference: frame_center_s={t_ref:.9f} acq_s={acq_s:.9f}") + phi_by_tx = {} + for tx in TX_ORDER: + (t0_abs, t1_abs), slot, n_slots = seg_of[tx] + seg_start = t0_abs - first + seg_end = t1_abs - first + t_seg = seg_end - seg_start + t_center = 0.5 * (seg_start + seg_end) + + # compute_frequency_sample_times, segmented branch. + dt_event = t_seg / (n_slots * N_FREQ) + t_abs = seg_start + (n_slots * np.arange(N_FREQ) + slot) * dt_event + + dt_intra = t_abs - t_center + dtau_intra = 2.0 * motion_factor * dt_intra / SPEED_OF_LIGHT + dt_pair = t_center - t_ref + dtau_pair = 2.0 * motion_factor * dt_pair / SPEED_OF_LIGHT + phi = 2.0 * np.pi * freqs * (dtau_intra + dtau_pair) + phi_by_tx[tx] = phi + + wrapped = wrap(phi) + print( + f"int_minus tx o{tx}: " + + " ".join(f"phi(k={k})={wrapped[k]:.12e}" for k in PROBE_INDICES) + ) + + for tx in TX_ORDER: + phi = phi_by_tx[tx] + # int_focus: remove the constant + linear-in-frequency component + # (least squares on [1, f_rel], notebook intra_phase_focus_only). + f_rel = freqs - freqs.mean() + design = np.column_stack([np.ones_like(f_rel), f_rel]) + beta, *_ = np.linalg.lstsq(design, phi, rcond=None) + residual = phi - design @ beta + wrapped = wrap(residual) + print( + f"int_focus tx o{tx}: " + + " ".join(f"phi(k={k})={wrapped[k]:.12e}" for k in PROBE_INDICES) + ) + + +if __name__ == "__main__": + main() diff --git a/python_app/tests/test_gui_profile_codec.py b/python_app/tests/test_gui_profile_codec.py index fdd5099..2503aba 100644 --- a/python_app/tests/test_gui_profile_codec.py +++ b/python_app/tests/test_gui_profile_codec.py @@ -68,6 +68,18 @@ class GuiProfileCodecTest(unittest.TestCase): with self.assertRaises(ValueError): # combos_text must be a JSON string, not a number GuiProfileModel.from_dict({"gui": {"switches": {"combos_text": 123}}}) + def test_invalid_gpr_timing_mode_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "timing_mode"): + GuiProfileModel.from_dict({"gui": {"processing": {"gpr": {"timing_mode": "bogus"}}}}) + + def test_invalid_gpr_plane_mode_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "plane_mode"): + GuiProfileModel.from_dict({"gui": {"processing": {"gpr": {"plane_mode": "bogus"}}}}) + + def test_invalid_gpr_tx_per_segment_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "tx_per_segment"): + GuiProfileModel.from_dict({"gui": {"processing": {"gpr": {"tx_per_segment": 0}}}}) + def test_legacy_gpr_payload_migrates_selected_mode(self) -> None: # An old payload that selects 'gpr' but carries a legacy root gpr.mode is migrated. decoded = GuiProfileModel.from_dict({