diff --git a/README.md b/README.md index ef46c90..8b5cf2a 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ and a **Python application** for supervision, visualization and control. The pipeline runs as three independent processes connected by lock-free POSIX shared-memory ring buffers. Each stage advances on its own, and a slow consumer -never blocks a producer — the rings are latest-wins, so a reader that falls behind -skips overwritten frames and continues from the retained tail: +never blocks a producer — the rings are latest-wins: a full ring overwrites its oldest +unread frame, a reader that falls behind continues with the frames still retained, and +one overtaken during a read resumes at the newest frame: 1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps, and publish raw S21 traces. Both single and multi-device LibreVNA use @@ -65,7 +66,8 @@ controls, processing settings and a copy of the live plot through an embedded we ## Processing modes - **pass_through** — magnitude and phase of S21 for each combo. -- **bscan** — depth-versus-sweep B-scan heatmap. +- **bscan** — depth-versus-sweep B-scan heatmap. A band selecting fewer than two + sweep points gives no range profile, and such sweeps are left out of the image. - **gpr** — coherent back-projection on the ground plane, as in `Horns_motion_4x4_ground.py`, with object detection. Radar height and look angle define the slice in radar X/Z axes; the wave speed is 3×10⁸ m/s (air). @@ -100,7 +102,7 @@ peak; its size follows the contiguous half-maximum width along the volume axes (FWHM, at least one voxel). This measures the imaged response rather than the reflector's physical dimensions. -The desktop uses a light theme with dark plot canvases. **Mode** stays below the +The desktop uses a light theme with white plot canvases. **Mode** stays below the **Actions** buttons while the settings sections scroll; the settings panel and runtime log can be collapsed with their arrow buttons. SAR3D status, peak coordinates and level appear above the projections. @@ -522,10 +524,14 @@ Profile loading blocks editor signals until all settings are populated. `HistoryController` owns retained acquisitions; `PlotController` selects the view and supplies independent B-scan, GPR, trace and SAR renderers with typed settings. Renderers own their plots and display caches, without access to the acquisition -processes. Stopped B-scan replay uses the last run's ring, matches exact acquisition -keys and the requested settings revision, and cancels when that run changes. With -no retained preprocessed sweeps (including disabled taps), the processor recomputes -its own retained history. Capture previews preserve the selected processing view. +processes. Every plot change passes through `PlotController`, which counts changes +of the shown image for the browser stream. The display shows the newest acquisition, +including results read while stopping. Stopped B-scan replay uses the last run's +ring, matches exact acquisition keys and the requested settings revision, and +cancels when that run changes; sweeps the processor does not return are left out +rather than mixing settings in one image. With no retained preprocessed sweeps +(including disabled taps), the processor recomputes its own retained history. +Capture previews preserve the selected processing view. `PipelineController` owns processes, ring readers, polling/recovery, Single Capture and replay. Its pause prevents recovery from starting a producer over a hardware capture; Stop or Close cancels any pending resumption, including during a drain. diff --git a/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp b/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp index 282c57c..0eee4de 100644 --- a/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp +++ b/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp @@ -29,7 +29,7 @@ class ShmRing { // Pushes payload with overwrite-oldest policy on overflow. [[nodiscard]] auto push(std::span payload) -> bool; - // Pops the next payload if available. + // Pops the next unread payload; false only when none remains. [[nodiscard]] auto pop(std::vector& payload) -> bool; private: diff --git a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp index cae935c..62a7725 100644 --- a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp +++ b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp @@ -1,5 +1,6 @@ #include "shm_ring.hpp" +#include #include #include #include @@ -220,41 +221,43 @@ auto ShmRing::pop(std::vector& payload) -> bool { throw std::runtime_error("Ring is not open"); } - const auto read_seq = header_->read_seq.load(std::memory_order_relaxed); - const auto write_seq = header_->write_seq.load(std::memory_order_acquire); - if (read_seq >= write_seq) { - return false; + while (true) { + const auto read_seq = header_->read_seq.load(std::memory_order_relaxed); + const auto write_seq = header_->write_seq.load(std::memory_order_acquire); + if (read_seq >= write_seq) { + return false; + } + // A lapped, torn or corrupt slot is skipped with the backlog behind the newest + // payload, which is read next (latest-wins); a bad slot never stops the consumer. + const auto resync = std::max(read_seq + 1U, write_seq - 1U); + + auto* slot = slot_header(read_seq); + if (std::atomic_ref(slot->sequence).load(std::memory_order_acquire) != read_seq + 1U) { + advance_read_seq(resync); // Overwritten before this read (lapped). + continue; + } + + const auto payload_size = slot->payload_size; + if (payload_size > header_->slot_size_bytes) { + advance_read_seq(resync); + continue; + } + + std::atomic_thread_fence(std::memory_order_acquire); + payload.resize(payload_size); + std::memcpy(payload.data(), slot_payload(slot), payload_size); + + // Seqlock verify: if the slot sequence changed during the copy, the producer + // lapped us mid-read and the payload is torn. + std::atomic_thread_fence(std::memory_order_acquire); + if (std::atomic_ref(slot->sequence).load(std::memory_order_acquire) != read_seq + 1U) { + advance_read_seq(resync); + continue; + } + + advance_read_seq(read_seq + 1U); + return true; } - - auto* slot = slot_header(read_seq); - if (std::atomic_ref(slot->sequence).load(std::memory_order_acquire) != read_seq + 1U) { - // Slot not published for this seq, or already overwritten (lapped). Resync. - advance_read_seq(write_seq); - return false; - } - - const auto payload_size = slot->payload_size; - if (payload_size > header_->slot_size_bytes) { - // Corrupt/torn slot: skip it (resync) rather than throw — a single bad slot - // must never abort the long-running consumer. - advance_read_seq(write_seq); - return false; - } - - std::atomic_thread_fence(std::memory_order_acquire); - payload.resize(payload_size); - std::memcpy(payload.data(), slot_payload(slot), payload_size); - - // Seqlock verify: if the slot sequence changed during the copy, the producer - // lapped us mid-read and the payload is torn — discard and resync. - std::atomic_thread_fence(std::memory_order_acquire); - if (std::atomic_ref(slot->sequence).load(std::memory_order_acquire) != read_seq + 1U) { - advance_read_seq(write_seq); - return false; - } - - advance_read_seq(read_seq + 1U); - return true; } void ShmRing::advance_read_seq(std::uint64_t target) { diff --git a/data_acq_and_processing/processing/data_processor/include/data_processor.hpp b/data_acq_and_processing/processing/data_processor/include/data_processor.hpp index 381dcff..50c41d4 100644 --- a/data_acq_and_processing/processing/data_processor/include/data_processor.hpp +++ b/data_acq_and_processing/processing/data_processor/include/data_processor.hpp @@ -38,8 +38,11 @@ class DataProcessor { const ProcessingLiveConfig& live_config ) -> ipc::ResultCollection; + [[nodiscard]] auto requested_mode(const ProcessingLiveConfig& live_config) const -> const std::string&; [[nodiscard]] auto resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&; [[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool; + // Only gpr and sar3d use the speed, so only they recompute when a locator client changes it. + [[nodiscard]] auto uses_socket_speed(const ProcessingLiveConfig& live_config) const -> bool; // Speed streamed by a locator client (`vlc`), which then replaces // `gpr_speed_m_s`; nullopt when `ignore_socket_speed` is set or no fresh 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 f612cb9..7ad1137 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 @@ -76,9 +76,8 @@ void DataProcessor::run(const std::atomic& stop_requested) { std::uint64_t last_replayed_revision = live_config_loader_.revision(); runtime::CollectionErrors errors; std::uint64_t oversized_results = 0; - // track the socket-fed speed used for the last reprocess so a change - // arriving without a live-config revision bump still triggers a reprocess of - // the current result (gated below by reprocess_current_result). + // The socket-fed speed used by the last reprocess: in the modes that use the speed, a + // change without a live-config revision still recomputes the current result. std::optional last_reprocessed_socket_speed = std::nullopt; while (!stop_requested.load(std::memory_order_relaxed)) { @@ -97,7 +96,8 @@ void DataProcessor::run(const std::atomic& stop_requested) { } auto& processor = resolve_processor(live_config); const auto history_limit = processing_history_limit(config_, live_config); - const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed; + const bool socket_speed_dirty = + uses_socket_speed(live_config) && current_socket_speed != last_reprocessed_socket_speed; const auto was_removed = [&](const ipc::PreprocessedCollection& collection) { if (live_config.history_clear_before_ns && @@ -111,39 +111,36 @@ void DataProcessor::run(const std::atomic& stop_requested) { }); }; if (live_revision != last_replayed_revision || socket_speed_dirty) { + // Settled before recomputing: a sweep that fails is reported once rather than + // retried every tick, which would stop all new input. + last_replayed_revision = live_revision; + last_reprocessed_socket_speed = current_socket_speed; std::erase_if(preprocessed_history, was_removed); if (preprocessed_history.size() > history_limit) { preprocessed_history.erase(preprocessed_history.begin(), preprocessed_history.end() - history_limit); } - 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], - std::span(preprocessed_history.data(), index), - processor, - live_config - ); - publish_result_collection(replay_result, results_ring_, oversized_results); - publish_locator(replay_result); + // The GUI turns this off while it re-feeds the sweeps of a stopped B-scan itself. + if (live_config.reprocess_current_result && !preprocessed_history.empty()) { + const auto newest = preprocessed_history.size() - 1U; + const auto first = should_replay_entire_history(live_config) ? std::size_t{0} : newest; + for (auto index = first; index <= newest; ++index) { + try { + const auto replay_result = process_collection( + preprocessed_history[index], + std::span(preprocessed_history.data(), index), + processor, + live_config + ); + publish_result_collection(replay_result, results_ring_, oversized_results); + if (index == newest) { + publish_locator(replay_result); // The locator reports the current scene. + } + } catch (const std::exception& exc) { + errors.report("data_processor", exc); + } } - } else if (!preprocessed_history.empty()) { - const auto replay_result = process_collection( - preprocessed_history.back(), - std::span(preprocessed_history.data(), preprocessed_history.size() - 1U), - processor, - live_config - ); - publish_result_collection(replay_result, results_ring_, oversized_results); - publish_locator(replay_result); } - last_replayed_revision = live_revision; - //record the speed we just reprocessed with so an - // unchanged socket value does not retrigger every iteration. - last_reprocessed_socket_speed = current_socket_speed; } if (preprocessed_ring_.pop(bytes)) { @@ -219,11 +216,12 @@ auto DataProcessor::process_collection( return result; } -auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface& { - const std::string requested_mode = - live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; +auto DataProcessor::requested_mode(const ProcessingLiveConfig& live_config) const -> const std::string& { + return live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; +} - if (auto found = processors_.find(requested_mode); found != processors_.end()) { +auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface& { + if (auto found = processors_.find(requested_mode(live_config)); found != processors_.end()) { return *(found->second); } if (auto fallback = processors_.find(default_processor_mode_); fallback != processors_.end()) { @@ -233,9 +231,12 @@ auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) - } auto DataProcessor::should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool { - const std::string requested_mode = - live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; - return requested_mode == "bscan"; + return requested_mode(live_config) == "bscan"; +} + +auto DataProcessor::uses_socket_speed(const ProcessingLiveConfig& live_config) const -> bool { + const auto& mode = requested_mode(live_config); + return mode == "gpr" || mode == "sar3d"; } auto DataProcessor::socket_speed(const ProcessingLiveConfig& live_config) const -> std::optional { diff --git a/data_acq_and_processing/processing/processors/src/bscan_processor.cpp b/data_acq_and_processing/processing/processors/src/bscan_processor.cpp index 6206572..bb47bd1 100644 --- a/data_acq_and_processing/processing/processors/src/bscan_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/bscan_processor.cpp @@ -93,24 +93,9 @@ void fft_inplace(std::vector>& values, bool inverse) { return std::abs(sample); } -[[nodiscard]] auto fallback_profile( - const ipc::SweepTraceBlock& trace, - std::span selected_samples -) -> BScanProfile { - const std::size_t point_count = std::min(trace.frequency_hz.size(), selected_samples.size()); - BScanProfile fallback{}; - fallback.depth_m.reserve(point_count); - fallback.response.reserve(point_count); - - const float denominator = point_count > 1U ? static_cast(point_count - 1U) : 1.0F; - for (std::size_t index = 0U; index < point_count; ++index) { - const auto& sample = selected_samples[index]; - fallback.depth_m.push_back(static_cast(static_cast(index) / denominator)); - fallback.response.push_back(std::sqrt(sample.re * sample.re + sample.im * sample.im)); - } - return fallback; -} - +// A range profile needs at least two in-band points on an increasing frequency grid. +// Otherwise the profile is empty and the display leaves the column out: drawing |S21| +// against a made-up depth axis would look like a valid B-scan. [[nodiscard]] auto compute_bscan_profile( const ipc::SweepTraceBlock& trace, const ProcessingLiveConfig& live_config @@ -118,7 +103,7 @@ void fft_inplace(std::vector>& values, bool inverse) { const auto selected_samples = std::span(trace.s21); const std::size_t point_count = std::min(trace.frequency_hz.size(), selected_samples.size()); if (point_count < 2U) { - return fallback_profile(trace, selected_samples); + return {}; } const double configured_start_hz = static_cast(live_config.bscan_start_freq_mhz) * 1'000'000.0; @@ -142,35 +127,35 @@ void fft_inplace(std::vector>& values, bool inverse) { } if (filtered_freq_hz.size() < 2U) { - return fallback_profile(trace, selected_samples); + return {}; } const std::size_t filtered_count = filtered_freq_hz.size(); const double df = (filtered_freq_hz.back() - filtered_freq_hz.front()) / static_cast(filtered_count - 1U); if (df <= 0.0) { - return fallback_profile(trace, selected_samples); + return {}; } const auto start_bin = static_cast(std::llround(filtered_freq_hz.front() / df)); if (start_bin < 0) { - return fallback_profile(trace, selected_samples); + return {}; } const auto start_index = static_cast(start_bin); if (start_index > (std::numeric_limits::max() / 2U)) { - return fallback_profile(trace, selected_samples); + return {}; } if (start_index > (std::numeric_limits::max() - filtered_count + 1U)) { - return fallback_profile(trace, selected_samples); + return {}; } const std::size_t min_fft_len = 2U * (start_index + filtered_count - 1U); const std::size_t fft_len = next_power_of_two(min_fft_len); if (fft_len < min_fft_len || (fft_len & (fft_len - 1U)) != 0U) { - return fallback_profile(trace, selected_samples); + return {}; } if (start_index > fft_len || filtered_count > (fft_len - start_index)) { - return fallback_profile(trace, selected_samples); + return {}; } std::vector> spectrum(fft_len, std::complex(0.0, 0.0)); diff --git a/device_firmware/librevna_protocol.md b/device_firmware/librevna_protocol.md index dceac3d..25c4da8 100644 --- a/device_firmware/librevna_protocol.md +++ b/device_firmware/librevna_protocol.md @@ -237,6 +237,11 @@ ref» — сигнал на первом приёмнике. слейвов на барьере синхронизации. Слив очередей, ожидание начала цикла и повторные попытки сбора в окно не входят. +Внутри свипа индекс точки только растёт, поэтому цикл прибора закрывается на +первой точке с неувеличившимся индексом — начале следующего свипа, даже если его +точка 0 потеряна. Точки следующего свипа в цикл не попадают. Потерянные точки +цикла становятся нулём; если таких больше 5 % трассы, цикл собирается заново. + ### Формат коллекций между процессами хоста Это отдельный формат от USB-пакетов прибора. `RAW3` и `PRP3` — четыре ASCII-байта diff --git a/python_app/gui/controllers/bscan_replay.py b/python_app/gui/controllers/bscan_replay.py index 397ea03..cfa3b4a 100644 --- a/python_app/gui/controllers/bscan_replay.py +++ b/python_app/gui/controllers/bscan_replay.py @@ -116,14 +116,14 @@ class BscanReplay: finally: self.active = False - # Tap and results rings drop independently: only these replayed keys belong - # in the refreshed image; unrelated runtime results may still use old settings. + # Tap and results rings drop independently: only these replayed keys belong in the + # refreshed image. One image never mixes settings, so unreturned sweeps are left out. self.history.replay_results[:] = [replayed[key] for key in keys if key in replayed] received = sum(key in replayed for key in keys[:sent]) if received < sent: self._warning( f"B-scan replay recovered {received} of {sent} re-sent sweeps; " - "some columns may still show their captured settings.", + "the image leaves the others out.", once_key=f"bscan_replay_incomplete_{sent}_{received}", ) self._debug(f"B-scan replay finished: sent={sent}, recovered={received}.") diff --git a/python_app/gui/controllers/live_settings_controller.py b/python_app/gui/controllers/live_settings_controller.py index 64bd391..feb012a 100644 --- a/python_app/gui/controllers/live_settings_controller.py +++ b/python_app/gui/controllers/live_settings_controller.py @@ -120,27 +120,15 @@ class LiveSettingsController: def _redraw(self) -> None: try: - mode = self._mode.currentText() - pipeline, plots, history = self._pipeline, self._pipeline.plots, self._pipeline.history - if mode == "bscan": - if pipeline.can_reprocess_history(): - pipeline.schedule_replay() - else: - pipeline.drain_results(timeout_s=0.25, poll_s=0.01) - plots.draw_bscan() + mode, pipeline = self._mode.currentText(), self._pipeline + if mode == "bscan" and pipeline.can_reprocess_history(): + pipeline.schedule_replay() + elif mode == "bscan": + pipeline.drain_results(timeout_s=0.25, poll_s=0.01) elif mode in _GEOMETRY_MODES: - latest = pipeline.drain_results(timeout_s=0.8, poll_s=0.02) - collection = latest if latest is not None else history.results[-1] if history.results else None - if mode == "sar3d": - plots.sar3d.clear() - if collection is not None: - plots.draw(collection) - elif collection is None or not plots.draw(collection): - plots.clear_gpr() - elif history.results: - plots.draw(history.results[-1]) - else: - plots.trace.clear() + # Only the published revision is drawn: wait for the newest sweep recomputed with it. + pipeline.drain_results(timeout_s=0.8, poll_s=0.02, revision=pipeline.plots.required_revision) + pipeline.plots.redraw() except Exception as exc: self._log.show_exception("Failed to render processing settings", exc) @@ -171,7 +159,7 @@ class LiveSettingsController: def locator_changed(self, *_args: object) -> None: if self._mode.currentText() == "gpr" and self._publish_changes(): - self.visual_changed() + self._redraw() def set_mode_page(self, mode: str) -> None: pages = self._processing.pages diff --git a/python_app/gui/controllers/pipeline_controller.py b/python_app/gui/controllers/pipeline_controller.py index e2567cf..c1ee6a2 100644 --- a/python_app/gui/controllers/pipeline_controller.py +++ b/python_app/gui/controllers/pipeline_controller.py @@ -80,6 +80,9 @@ class PipelineController: self._raw_reader: ShmRingReader | None = None self._pre_reader: ShmRingReader | None = None self._result_reader: ShmRingReader | None = None + # Results read anywhere (polling, Stop/pause drains, snapshots) await display; + # the next poll shows the newest acquisition, not merely the last one read. + self._display_pending = False self.active_config: RunConfigModel | None = None self.active_config_path: Path | None = None self.stop_generation = 0 @@ -203,6 +206,7 @@ class PipelineController: raise error if not operation.valid: raise InterruptedError("Pipeline operation cancelled") + self._display_pending = False # the pausing operation owns the display return operation except BaseException: operation.finish(resume=False) @@ -478,11 +482,14 @@ class PipelineController: finally: if self.single_capture_active: self._check_single_capture_deadline() - elif latest[2] is not None: - self._restart_count = 0 - started = time.monotonic_ns() - self.plots.draw(latest[2]) - self.metrics.record("rendering", time.monotonic_ns() - started) + elif self._display_pending: + if latest[2] is not None: + self._restart_count = 0 # results flow again + self._display_pending = False + if self.history.results: + started = time.monotonic_ns() + self.plots.draw(self.history.results[-1]) + self.metrics.record("rendering", time.monotonic_ns() - started) except Exception as exc: self._report_ui_error("Result display", exc) @@ -634,6 +641,7 @@ class PipelineController: self.metrics.record("processing", collection.processing_duration_ns) if self.history.record_result(collection): self.recording.record("results", collection) + self._display_pending = True latest = collection return latest @@ -662,17 +670,20 @@ class PipelineController: stable = 0 if self.drain_once() else stable + 1 self.pump_events(poll_s) - def drain_results(self, *, timeout_s: float, poll_s: float) -> ResultCollection | None: - deadline, stable = time.monotonic() + timeout_s, 0 - latest_seen = None - while self._result_reader is not None and time.monotonic() < deadline and (latest_seen is None or stable < 2): - latest = self.read_results() - if latest is not None: - latest_seen, stable = latest, 0 - elif latest_seen is not None: + def drain_results(self, *, timeout_s: float, poll_s: float, revision: int | None = None) -> None: + """Read results until the timeout; stop two quiet polls after results arrive or, + given a revision, as soon as the newest result carries it.""" + deadline, seen, stable = time.monotonic() + timeout_s, False, 0 + while self._result_reader is not None and time.monotonic() < deadline: + if self.read_results() is not None: + seen, stable = True, 0 + elif seen: stable += 1 + if revision is None and stable >= 2: + return + if revision is not None and self.history.results and self.history.results[-1].settings_revision == revision: + return self.pump_events(poll_s) - return latest_seen def drain_snapshot(self) -> None: initial_readers = self.readers diff --git a/python_app/gui/controllers/plot_controller.py b/python_app/gui/controllers/plot_controller.py index 22ba0ed..09f1152 100644 --- a/python_app/gui/controllers/plot_controller.py +++ b/python_app/gui/controllers/plot_controller.py @@ -2,6 +2,7 @@ from collections.abc import Callable +import pyqtgraph as pg from PyQt6.QtCore import QSignalBlocker from PyQt6.QtWidgets import QComboBox, QPushButton, QStackedWidget, QWidget @@ -14,8 +15,12 @@ from python_app.models.dataset_model import ResultCollection, TraceData from python_app.models.gui_profile_schema import GuiProcessingStateModel from python_app.models.run_config_schema import GprModel +Renderer = BscanRenderer | GprRenderer | Sar3dRenderer | TraceRenderer + class PlotController: + """Own the plot views: renderers draw and clear only through it, and it counts image changes.""" + def __init__( self, history: HistoryController, settings: Callable[[], GuiProcessingStateModel], geometry: Callable[[], GprModel], running: Callable[[], bool], @@ -36,9 +41,28 @@ class PlotController: for renderer in (self.bscan, self.gpr, self.sar3d, self.trace): self.stack.addWidget(renderer.widget) self.stack.setCurrentWidget(self.trace.widget) + # Counts changes of the shown image (drawing, page, size, view range, a legend click + # hiding a curve) for observers such as the web stream. QGraphicsScene.changed must stay + # unconnected: it switches Qt to a legacy update path that leaves stale pixels behind. + self.revision = 0 + self.stack.currentChanged.connect(self._changed) + for plot in self.stack.findChildren(pg.PlotWidget): + item = plot.getPlotItem() + assert item is not None + view = item.getViewBox() + view.sigResized.connect(self._changed) + view.sigRangeChanged.connect(self._changed) + for panel in (self.trace.magnitude, self.trace.phase): + panel.legend.sigSampleClicked.connect(self._changed) self._combo: QComboBox | None = None self._next: QPushButton | None = None + def _changed(self, *_args: object) -> None: + self.revision += 1 + + def _renderer(self, mode: str) -> Renderer: + return {"bscan": self.bscan, "gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace) + def bind_display_controls(self, combo: QComboBox, next_button: QPushButton) -> None: self._combo, self._next = combo, next_button combo.currentIndexChanged.connect(self.on_display_combo_changed) @@ -55,8 +79,7 @@ class PlotController: return self.sar3d.grid if current is self.sar3d.widget else current def set_mode(self, mode: str) -> None: - renderer = {"bscan": self.bscan, "gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace) - self.stack.setCurrentWidget(renderer.widget) + self.stack.setCurrentWidget(self._renderer(mode).widget) self.sync_display_combos() def select_combo(self, input_pos: int, output_pos: int) -> None: @@ -96,6 +119,7 @@ class PlotController: self.sync_display_combos() def draw_bscan(self) -> bool: + self._changed() self.sync_bscan() window, retained = self.window_scans(), len(self.history.preprocessed) if retained < window and self.bscan.history_by_combo: @@ -119,6 +143,7 @@ class PlotController: return False if mode == "bscan": return self.draw_bscan() + self._changed() if mode == "gpr": return self.gpr.draw(collection, settings.gpr, self._geometry()) if mode == "sar3d": @@ -126,42 +151,36 @@ class PlotController: return self.trace.draw(collection, settings.pass_through) def redraw(self, *_args: object) -> None: + """Show the newest result with the current settings.""" mode = self._settings().selected_mode if mode == "bscan": self.draw_bscan() - elif mode == "sar3d": - self.sar3d.clear() - if self.history.results: - self.draw(self.history.results[-1]) - elif not self.history.results or not self.draw(self.history.results[-1]): - self.clear_current() + return + # Cleared first: a result of another settings revision is not drawn and must not + # leave the previous image, while a renderer's own failure message is kept. + self._changed() + self._renderer(mode).clear() + if self.history.results: + self.draw(self.history.results[-1]) def draw_trace(self, trace: TraceData, title: str) -> None: + self._changed() self.trace.draw_trace(trace, title, self._settings().pass_through) + def clear_trace(self) -> None: + self._changed() + self.trace.clear() + def clear_bscan(self) -> None: + self._changed() self.bscan.clear() self.sync_display_combos() - def clear_gpr(self) -> None: - self.gpr.clear() - - def clear_current(self) -> None: - mode = self._settings().selected_mode - if mode == "bscan": - self.clear_bscan() - elif mode == "gpr": - self.clear_gpr() - elif mode == "sar3d": - self.sar3d.clear() - else: - self.trace.clear() - def clear(self) -> None: - self.clear_bscan() - self.clear_gpr() - self.sar3d.clear() - self.trace.clear() + self._changed() + for renderer in (self.bscan, self.gpr, self.sar3d, self.trace): + renderer.clear() + self.sync_display_combos() @staticmethod def has_output(collection: ResultCollection) -> bool: diff --git a/python_app/gui/controllers/preprocess_controller.py b/python_app/gui/controllers/preprocess_controller.py index 726b946..6bd2366 100644 --- a/python_app/gui/controllers/preprocess_controller.py +++ b/python_app/gui/controllers/preprocess_controller.py @@ -324,7 +324,7 @@ class PreprocessController: dialog.clear_capture_log() dialog.reset_preview() dialog.set_status(f"{preprocess_asset_display_name(kind)} sequence started") - self._plots.trace.clear() + self._plots.clear_trace() self._update_capture_state() logger.info("%s sequence started: set=%s, combos=%d, radar_variants=%d, median_sweeps=%d", kind, name, session.state().total_count, len(keys), settings.median_sweep_count) @@ -417,7 +417,7 @@ class PreprocessController: trace = session.last_captured_trace() if trace is None: dialog.reset_preview() - self._plots.trace.clear() + self._plots.clear_trace() else: dialog.draw_last_trace(trace, title=f"{display} last trace") self._plots.draw_trace(trace, f"{display} last trace") diff --git a/python_app/gui/controllers/renderers/bscan.py b/python_app/gui/controllers/renderers/bscan.py index f255109..e63b3c4 100644 --- a/python_app/gui/controllers/renderers/bscan.py +++ b/python_app/gui/controllers/renderers/bscan.py @@ -4,14 +4,13 @@ from __future__ import annotations from collections import deque -from PyQt6.QtCore import QRectF import numpy as np import pyqtgraph as pg from python_app.models.collection_identity import collection_key from python_app.models.dataset_model import ResultCollection from python_app.models.gui_profile_schema import GuiBscanStateModel -from python_app.gui.controllers.renderers.common import build_lut, create_plot +from python_app.gui.controllers.renderers.common import build_lut, create_plot, image_rect def _result_tail( @@ -109,16 +108,18 @@ def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]: return -max_abs, max_abs +_TITLE = "B-scan S21" + + class BscanRenderer: """Own the heatmap and its per-combination arrays; input history stays external.""" def __init__(self) -> None: - self.widget = create_plot() + self.widget = create_plot(_TITLE) self.widget.setLabel("bottom", "Sweep #") self.widget.setLabel("left", "Range", units="m") item = self.widget.getPlotItem() assert item is not None - item.getViewBox().invertY(False) item.getViewBox().enableAutoRange(x=False, y=False) self.image = pg.ImageItem(axisOrder="row-major") item.addItem(self.image) @@ -139,24 +140,28 @@ class BscanRenderer: history = self.history_by_combo.get(key) if key is not None else None depth = self.depth_axis_by_combo.get(key) if key is not None else None if not history or depth is None: - self.image.clear() + self._show_empty() return False sweeps = apply_mean_ascan_subtraction(history, enabled=settings.subtract_mean_ascan) - depth_min, depth_max = float(np.min(depth)), float(np.max(depth)) count = sweeps.shape[0] + rect = image_rect(np.arange(1, count + 1), depth) self.image.setImage(sweeps.T, autoLevels=False) - self.image.setRect(QRectF(.5, depth_min, float(count), max(depth_max - depth_min, 1e-6))) + self.image.setRect(rect) self.image.setLookupTable(bscan_lookup_table(settings.axis)) self.image.setLevels(bscan_levels(sweeps, settings.axis)) item = self.widget.getPlotItem() assert item is not None - item.getViewBox().setRange(xRange=(.5, .5 + count), yRange=(depth_min, depth_max), padding=.02) + item.getViewBox().setRange(rect, padding=.02) assert key is not None - self.widget.setTitle(f"B-scan S21 in{key[0]}/out{key[1]} | sweeps={count}") + self.widget.setTitle(f"{_TITLE} in{key[0]}/out{key[1]} | sweeps={count}") return True def clear(self) -> None: self.history_by_combo.clear() self.depth_axis_by_combo.clear() self.signature = None + self._show_empty() + + def _show_empty(self) -> None: self.image.clear() + self.widget.setTitle(_TITLE) diff --git a/python_app/gui/controllers/renderers/common.py b/python_app/gui/controllers/renderers/common.py index bab5a46..84a4fe8 100644 --- a/python_app/gui/controllers/renderers/common.py +++ b/python_app/gui/controllers/renderers/common.py @@ -1,15 +1,25 @@ -"""Plot construction and color tables shared by radar views.""" +"""Plot construction, colors and color tables shared by radar views.""" import numpy as np import pyqtgraph as pg +from PyQt6.QtCore import QRectF +from python_app.gui.theme import PLOT_FOREGROUND from python_app.models.run_config_schema import GprModel from python_app.models.run_config_validation import parse_position_csv -from python_app.gui.theme import PLOT_BACKGROUND + +# Series identity colors in fixed order, checked for the white plot surface: lightness band, +# chroma floor and neighbour separation under normal and colorblind vision. Aqua, yellow and +# pink stay under 3:1 contrast with white, so lines are 2 px wide and the legend names pairs. +SERIES_COLORS = ("#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948") +# A captured trace shown on its own (main view, capture preview, PNG export). +CAPTURED_TRACE_PEN = pg.mkPen(SERIES_COLORS[0], width=2) +# Behind legends and labels, so their text stays legible over curves and heatmaps. +OVERLAY_FILL = (255, 255, 255, 200) def create_plot(title: str = "") -> pg.PlotWidget: - plot = pg.PlotWidget(background=PLOT_BACKGROUND, enableMenu=False, title=title) + plot = pg.PlotWidget(enableMenu=False, title=title) plot.showGrid(x=True, y=True, alpha=0.2) return plot @@ -26,8 +36,20 @@ def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray: return lut -# Same Tx/Rx markers on ground BP and the three planar SAR views. -ANTENNA_STYLES = (("t", "#ff595e", "#ffca3a"), ("t1", "#4cc9f0", "#e0fbfc")) +def image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF: + """Rectangle covering image pixels centred on the axis samples; a lone sample spans one unit.""" + (x, width), (y, height) = (_pixel_span(axis) for axis in (x_axis, y_axis)) + return QRectF(x, y, width, height) + + +def _pixel_span(axis: np.ndarray) -> tuple[float, float]: + first, last = float(axis[0]), float(axis[-1]) + pitch = (last - first) / (len(axis) - 1) if len(axis) > 1 else 1.0 + return first - pitch / 2, last - first + pitch + + +# Same Tx/Rx markers (symbol, fill, outline) on ground BP and the three planar SAR views. +ANTENNA_STYLES = (("t", SERIES_COLORS[7], PLOT_FOREGROUND), ("t1", SERIES_COLORS[0], PLOT_FOREGROUND)) def selected_antenna_coordinates(geometry: GprModel, inputs: str, outputs: str) -> tuple[np.ndarray, np.ndarray]: diff --git a/python_app/gui/controllers/renderers/gpr.py b/python_app/gui/controllers/renderers/gpr.py index 8e94f8e..554241e 100644 --- a/python_app/gui/controllers/renderers/gpr.py +++ b/python_app/gui/controllers/renderers/gpr.py @@ -6,10 +6,13 @@ from PyQt6.QtCore import QRectF import numpy as np import pyqtgraph as pg -from python_app.gui.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates +from python_app.gui.controllers.renderers.common import ( + ANTENNA_STYLES, OVERLAY_FILL, build_lut, create_plot, image_rect, selected_antenna_coordinates, +) from python_app.gui.result_payloads import ( collection_payload_by_name, gpr_object_rows, ) +from python_app.gui.theme import PLOT_FOREGROUND from python_app.models.dataset_model import ResultCollection, ResultPayload from python_app.models.gui_profile_schema import GuiGprStateModel from python_app.models.run_config_schema import GprModel @@ -59,13 +62,16 @@ class GprRenderer: """Draw one collection from explicit display settings and antenna geometry.""" def __init__(self) -> None: - self.widget = create_plot("GPR coherent BP — ground plane") + self.widget = create_plot(_title()) plot = self.widget.getPlotItem() assert plot is not None self._plot = plot self._view = plot.getViewBox() - self._plot.showGrid(x=True, y=True, alpha=0.2) - self._configure_axes() + plot.setLabel("bottom", "X", units="m") + plot.setLabel("left", "Range", units="m") + plot.hideButtons() + self._view.enableAutoRange(x=False, y=False) + self._view.setMouseEnabled(x=False, y=False) self._lookup_table = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) self._image = pg.ImageItem(axisOrder="row-major") self._tx = pg.ScatterPlotItem() @@ -80,21 +86,6 @@ class GprRenderer: self._geometry_signature: tuple[object, ...] | None = None self._selected_geometry = (np.empty((0, 3)), np.empty((0, 3))) - def _configure_axes(self) -> None: - plot = self._plot - plot.showAxis("left", show=True) - plot.showAxis("bottom", show=True) - plot.setClipToView(True) - plot.setLabel("bottom", "X", units="m") - plot.setLabel("left", "Range", units="m") - plot.setMenuEnabled(False) - plot.hideButtons() - view = self._view - view.invertY(False) - view.enableAutoRange(x=False, y=False) - view.setMouseEnabled(x=False, y=False) - view.setMenuEnabled(False) - def _clear_annotations(self) -> None: for item in self._labels: self._plot.removeItem(item) @@ -102,11 +93,9 @@ class GprRenderer: def clear(self) -> None: """Clear displayed data while retaining reusable graphics items.""" - self._configure_axes() self._clear_annotations() self._image.hide() for marker in self._markers: - marker.setData(x=[], y=[]) marker.hide() self._plot.setTitle(_title()) @@ -128,8 +117,16 @@ class GprRenderer: brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0)) marker.setVisible(len(coordinates) > 0) - def _label(self, text: str, x: float, y: float, color: str) -> pg.TextItem: - label = pg.TextItem(text=text, color=color, anchor=(0.0, 1.0)) + def _draw_image(self, accumulator: ResultPayload) -> None: + image = np.asarray(accumulator.image, dtype=np.float32) + self._image.setImage(image, autoLevels=False) + self._image.setRect(image_rect(accumulator.image_x_axis, accumulator.image_y_axis)) + self._image.setLookupTable(self._lookup_table) + self._image.setLevels((float(np.min(image)), float(np.max(image) + 1e-6))) + self._image.show() + + def _label(self, text: str, x: float, y: float) -> pg.TextItem: + label = pg.TextItem(text=text, color=PLOT_FOREGROUND, fill=pg.mkBrush(*OVERLAY_FILL), anchor=(0.0, 1.0)) label.setZValue(40) label.setPos(x, y) self._plot.addItem(label) @@ -153,6 +150,7 @@ class GprRenderer: self, rows: np.ndarray, objects_only: bool, x_span: float, z_span: float, ) -> None: if rows.size == 0: + self._points.hide() return self._points.setData( x=rows[:, 0], y=rows[:, 1], symbol="o" if objects_only else "d", @@ -167,7 +165,7 @@ class GprRenderer: text = f"{float(score):.2f}" if objects_only: text += f" | x={x:.1f} | z={z:.1f}" - label = self._label(text, x, z, "#ffd6d9" if objects_only else "#ffffff") + label = self._label(text, x, z) if objects_only: self._place_object_label(label, x, z, x_span, z_span, occupied) @@ -175,8 +173,6 @@ class GprRenderer: """Render a heatmap or object-only view, returning whether data is present.""" objects_only = settings.render_mode == "objects_only" accumulator = collection_payload_by_name(collection, "gpr_accumulator", kind=3) - x_min, x_max = normalized_display_range(settings.visible_x_min_m, settings.visible_x_max_m) - z_min, z_max = normalized_display_range(settings.visible_z_min_m, settings.visible_z_max_m) rows = gpr_object_rows(collection) if objects_only: if accumulator is None and rows.size == 0: @@ -186,42 +182,18 @@ class GprRenderer: self.clear() return False - self.widget.setUpdatesEnabled(False) - try: - self._clear_annotations() + x_min, x_max = normalized_display_range(settings.visible_x_min_m, settings.visible_x_max_m) + z_min, z_max = normalized_display_range(settings.visible_z_min_m, settings.visible_z_max_m) + self._view.setXRange(x_min, x_max, padding=0.0) + self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0) + self._clear_annotations() + self._draw_geometry(settings, geometry) + if objects_only or accumulator is None: self._image.hide() - for marker in (self._points,): - marker.setData(x=[], y=[]) - marker.hide() - self._view.invertY(False) - self._draw_geometry(settings, geometry) - rect: QRectF | None = None - if not objects_only and accumulator is not None: - image = np.asarray(accumulator.image, dtype=np.float32) - xs = np.asarray(accumulator.image_x_axis, dtype=np.float32) - ys = np.asarray(accumulator.image_y_axis, dtype=np.float32) - rect = QRectF( - float(xs[0]), float(ys[0]), - max(float(xs[-1]) - float(xs[0]), 1e-6), - max(float(ys[-1]) - float(ys[0]), 1e-6), - ) - self._image.setImage(image, autoLevels=False) - self._image.setRect(rect) - self._image.setLookupTable(self._lookup_table) - self._image.setLevels((float(np.min(image)), float(np.max(image) + 1e-6))) - self._image.show() - self._view.setXRange(x_min, x_max, padding=0.0) - self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0) - points = collection_payload_by_name(collection, "gpr_points", kind=4) - if points is None or np.asarray(points.table).size == 0: - rows = np.zeros((0, 3), dtype=np.float32) - self._draw_points(rows, objects_only, x_max - x_min, z_max - z_min) - if objects_only: - self._view.setXRange(x_min, x_max, padding=0.0) - self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0) - self._plot.setTitle(_title(objects_only=objects_only)) - finally: - self.widget.setUpdatesEnabled(True) + else: + self._draw_image(accumulator) + self._draw_points(rows, objects_only, x_max - x_min, z_max - z_min) + self._plot.setTitle(_title(objects_only=objects_only)) return True @staticmethod diff --git a/python_app/gui/controllers/renderers/sar3d.py b/python_app/gui/controllers/renderers/sar3d.py index a4425c8..2d93508 100644 --- a/python_app/gui/controllers/renderers/sar3d.py +++ b/python_app/gui/controllers/renderers/sar3d.py @@ -24,8 +24,11 @@ import pyqtgraph as pg from PyQt6.QtCore import QRectF, Qt from PyQt6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget -from python_app.gui.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates +from python_app.gui.controllers.renderers.common import ( + ANTENNA_STYLES, build_lut, create_plot, image_rect, selected_antenna_coordinates, +) from python_app.gui.result_payloads import collection_payload_by_name +from python_app.gui.theme import PLOT_FOREGROUND from python_app.models.dataset_model import ResultCollection, ResultPayload from python_app.models.gui_profile_schema import GuiSar3dStateModel from python_app.models.run_config_schema import GprModel @@ -33,6 +36,9 @@ from python_app.models.run_config_schema import GprModel # Status codes published by the sar3d processor in its "sar3d_status" table. SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"} +_GROUND_COLOR = (115, 105, 88) +_SCENE_BOX_COLOR = "#898781" + # payload -> (title, x label, y label, scene axes (0 = X, 1 = Y, 2 = Z) along the plot axes) SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = { "sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)), @@ -61,13 +67,6 @@ def sar3d_status(collection: ResultCollection) -> tuple[int, str] | None: return code, bytes(row[1:].astype(np.uint8)).decode("utf-8", errors="replace") -def _image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF: - """Rectangle covering the pixels centred on the axis samples.""" - dx = (x_axis[-1] - x_axis[0]) / max(len(x_axis) - 1, 1) - dy = (y_axis[-1] - y_axis[0]) / max(len(y_axis) - 1, 1) - return QRectF(x_axis[0] - dx / 2, y_axis[0] - dy / 2, x_axis[-1] - x_axis[0] + dx, y_axis[-1] - y_axis[0] + dy) - - class Sar3dRenderer: """Draws the 3D SAR views.""" @@ -85,21 +84,19 @@ class Sar3dRenderer: grid_layout.setContentsMargins(0, 0, 0, 0) grid_layout.setSpacing(6) layout.addWidget(self.grid, stretch=1) - self.plots: dict[str, pg.PlotWidget] = {} - for index, (name, (title, *_rest)) in enumerate(SAR3D_VIEWS.items()): - plot = create_plot(title) - grid_layout.addWidget(plot, index // 2, index % 2) - self.plots[name] = plot lut = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) # the GPR palette lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)]) lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD + self.plots: dict[str, pg.PlotWidget] = {} self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {} self.images: dict[str, pg.ImageItem] = {} self.targets: dict[str, pg.PlotCurveItem] = {} - for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items(): - plot = self.plots[name] + for index, (name, (title, x_label, y_label, axes)) in enumerate(SAR3D_VIEWS.items()): + plot = create_plot(title) + grid_layout.addWidget(plot, index // 2, index % 2) + self.plots[name] = plot plot.setAspectLocked(True) - if _axes is not None: + if axes is not None: markers = (pg.ScatterPlotItem(), pg.ScatterPlotItem()) for marker in markers: marker.setZValue(4) @@ -109,42 +106,40 @@ class Sar3dRenderer: assert item is not None view = item.getViewBox() view.setMouseEnabled(x=False, y=False) - view.setMenuEnabled(False) view.enableAutoRange(x=False, y=False) item.hideButtons() image = pg.ImageItem(axisOrder="row-major", lut=lut) plot.addItem(image) # Hollow, so the response at the maximum stays visible. - peak = pg.PlotCurveItem(pen=pg.mkPen("w", width=1.5)) + peak = pg.PlotCurveItem(pen=pg.mkPen(PLOT_FOREGROUND, width=1.5)) peak.setZValue(3) plot.addItem(peak) self.images[name] = image self.targets[name] = peak if x_label: - plot.showGrid(x=True, y=True, alpha=0.2) plot.setLabel("bottom", x_label, units="m") plot.setLabel("left", y_label, units="m") if y_label == "Y": - ground = pg.InfiniteLine(pos=0.0, angle=0, pen=pg.mkPen("w", width=1, style=Qt.PenStyle.DashLine)) + ground = pg.InfiniteLine(pos=0.0, angle=0, + pen=pg.mkPen(_GROUND_COLOR, width=1, style=Qt.PenStyle.DashLine)) ground.setZValue(2) plot.addItem(ground) oblique = self.plots["sar3d_oblique"] oblique.hideAxis("bottom") oblique.hideAxis("left") - oblique.setAspectLocked(True) # The notebook scene's ground, under the image so it shows through weak voxels. self.ground = pg.PlotCurveItem( - pen=pg.mkPen(115, 105, 88), brush=pg.mkBrush(115, 105, 88, 110), fillLevel="enclosed" + pen=pg.mkPen(_GROUND_COLOR), brush=pg.mkBrush(*_GROUND_COLOR, 110), fillLevel="enclosed" ) self.ground.setZValue(-1) oblique.addItem(self.ground) - self.box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), connect="pairs") + self.box = pg.PlotCurveItem(pen=pg.mkPen(_SCENE_BOX_COLOR), connect="pairs") self.box.setZValue(2) oblique.addItem(self.box) self.axis_labels: dict[str, pg.TextItem] = {} for axis_name in _AXIS_EDGES: - label = pg.TextItem(axis_name, color=(190, 190, 190), anchor=(0.5, 0.5)) + label = pg.TextItem(axis_name, color=PLOT_FOREGROUND, anchor=(0.5, 0.5)) oblique.addItem(label) self.axis_labels[axis_name] = label self.clear() @@ -195,7 +190,7 @@ class Sar3dRenderer: zoom = settings.zoom_percent / 100.0 for name, payload in payloads.items(): title, _x_label, _y_label, axes = SAR3D_VIEWS[name] - rect = _image_rect(payload.image_x_axis, payload.image_y_axis) + rect = image_rect(payload.image_x_axis, payload.image_y_axis) image = self.images[name] image.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0), autoLevels=False, levels=(0, 1)) diff --git a/python_app/gui/controllers/renderers/trace.py b/python_app/gui/controllers/renderers/trace.py index 0911aa5..13ac98f 100644 --- a/python_app/gui/controllers/renderers/trace.py +++ b/python_app/gui/controllers/renderers/trace.py @@ -6,10 +6,10 @@ from typing import Protocol import numpy as np import pyqtgraph as pg -from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPen from PyQt6.QtWidgets import QVBoxLayout, QWidget -from python_app.gui.controllers.renderers.common import create_plot +from python_app.gui.controllers.renderers.common import CAPTURED_TRACE_PEN, OVERLAY_FILL, SERIES_COLORS, create_plot from python_app.models.dataset_model import ComboKey, ResultCollection, TraceData from python_app.models.gui_profile_schema import GuiPassThroughStateModel from python_app.models.run_config_validation import parse_combos_from_text @@ -21,9 +21,7 @@ class WarningLogger(Protocol): CurveKey = tuple[ComboKey, int, str] -Series = tuple[CurveKey, np.ndarray, np.ndarray, str] -_PALETTE = ("#4cc9f0", "#f72585", "#b8f2e6", "#ffd166", - "#90be6d", "#ff595e", "#6a4c93", "#1982c4") +Series = tuple[CurveKey, np.ndarray, np.ndarray, QPen] class _TracePanel: @@ -35,57 +33,49 @@ class _TracePanel: assert item is not None self.item = item self.view = item.getViewBox() - self.item.showGrid(x=True, y=True, alpha=0.2) self.item.setLabel("left", label, units=units) self.item.setLabel("bottom", "Frequency", units="Hz") - self.item.setDownsampling(mode="peak") - self.item.setClipToView(True) - self.curves: dict[CurveKey, pg.PlotCurveItem] = {} - self.legend: pg.LegendItem | None = None - self._legend_sources: dict[ComboKey, pg.PlotCurveItem] = {} + # Sweeps denser than the screen are drawn as per-bin min/max, so narrow peaks + # and notches stay visible; ordinary sweeps keep every point. + self.item.setDownsampling(auto=True, mode="peak") + self.curves: dict[CurveKey, pg.PlotDataItem] = {} + self.legend = self.item.addLegend(offset=(8, 8), brush=pg.mkBrush(*OVERLAY_FILL), colCount=2) + self.legend.hide() + self._legend_sources: dict[ComboKey, pg.PlotDataItem] = {} def clear(self) -> None: self.item.clear() self.curves.clear() self.sync_legend({}) - def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray, - color: str, *, width: float, dashed: bool = False) -> pg.PlotCurveItem: - pen = pg.mkPen(color, width=width, - style=Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine) + def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray, pen: QPen) -> pg.PlotDataItem: curve = self.curves.get(key) if curve is None: - curve = pg.PlotCurveItem(pen=pen, antialias=False) + curve = pg.PlotDataItem(antialias=False) self.curves[key] = curve self.item.addItem(curve) - else: - curve.setPen(pen) - curve.setData(x, y) + curve.setData(x, y, pen=pen) # one update; a separate setPen would redraw the old data first return curve def retain_curves(self, active_keys: set[CurveKey]) -> None: for key in self.curves.keys() - active_keys: self.item.removeItem(self.curves.pop(key)) - def sync_legend(self, sources: dict[ComboKey, pg.PlotCurveItem]) -> None: + def sync_legend(self, sources: dict[ComboKey, pg.PlotDataItem]) -> None: if self._legend_sources == sources: return - if self.legend is None and sources: - self.legend = self.item.addLegend(offset=(8, 8)) - if self.legend is not None: - self.legend.clear() - for combo in sorted(sources, key=lambda key: (key.input, key.output)): - self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}") - self.legend.setVisible(bool(sources)) + self.legend.clear() + for combo in sorted(sources, key=lambda key: (key.input, key.output)): + self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}") + self.legend.setVisible(bool(sources)) self._legend_sources = sources class TraceRenderer: """Render result traces or a captured trace using explicit display settings.""" - def __init__(self, log_warning: WarningLogger, max_points: int = 800) -> None: + def __init__(self, log_warning: WarningLogger) -> None: self._log_warning = log_warning - self.max_points = max_points self.widget = QWidget() layout = QVBoxLayout(self.widget) layout.setContentsMargins(0, 0, 0, 0) @@ -102,23 +92,24 @@ class TraceRenderer: def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool: combo_filter = self._combo_filter(settings.combo_filter) - colors: dict[ComboKey, str] = {} series: list[Series] = [] - for block in collection.blocks: + # The color follows the pair's place in the run, so filtering never repaints shown pairs. + for position, block in enumerate(collection.blocks): if combo_filter is not None and block.combo not in combo_filter: continue - color = colors.setdefault(block.combo, _PALETTE[len(colors) % len(_PALETTE)]) + pen = pg.mkPen(SERIES_COLORS[position % len(SERIES_COLORS)], width=2) for index, payload in enumerate(block.payloads): - if payload.kind != 1 or not payload.trace.size or payload.frequency_hz.size != payload.trace.size: + # Another mode's result (a B-scan range profile) is not S21 over frequency. + if payload.processing_name != "pass_through" or payload.kind != 1: continue series.append(((block.combo, index, payload.processing_name), - payload.frequency_hz, payload.trace, color)) + payload.frequency_hz, payload.trace, pen)) return self._render(series, settings, "Pass-Through S21") def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None: self.clear() self._render([((trace.combo, 0, "__single_trace__"), trace.frequency_hz, - trace.s21, "#ffd166")], settings, title, single=True) + trace.s21, CAPTURED_TRACE_PEN)], settings, title, single=True) def _combo_filter(self, text: str) -> set[ComboKey] | None: text = text.strip() @@ -142,27 +133,23 @@ class TraceRenderer: return False mag.item.showAxis("bottom", show=not settings.show_phase) active: set[CurveKey] = set() - mag_sources: dict[ComboKey, pg.PlotCurveItem] = {} - phase_sources: dict[ComboKey, pg.PlotCurveItem] = {} + mag_sources: dict[ComboKey, pg.PlotDataItem] = {} + phase_sources: dict[ComboKey, pg.PlotDataItem] = {} x_min, x_max = np.inf, -np.inf - for key, frequency, samples, color in series: + for key, frequency, samples, pen in series: if not samples.size or frequency.size != samples.size: continue active.add(key) x_min, x_max = min(x_min, float(np.min(frequency))), max(x_max, float(np.max(frequency))) - step = max(1, int(np.ceil(samples.size / self.max_points))) if self.max_points > 0 else 1 if settings.show_magnitude: - magnitude = 20.0 * np.log10(np.maximum(np.abs(samples[::step]), 1e-12)) - curve = mag.update_curve(key, frequency[::step], magnitude, color, - width=1.8 if single else 1.4) + magnitude = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) + curve = mag.update_curve(key, frequency, magnitude, pen) mag_sources.setdefault(key[0], curve) if settings.show_phase: angles = np.angle(samples) if settings.unwrap_phase: angles = np.unwrap(angles) - curve = phase.update_curve(key, frequency[::step], np.degrees(angles[::step]), - "#80ed99" if single else color, - width=1.4 if single else 1.2, dashed=single) + curve = phase.update_curve(key, frequency, np.degrees(angles), pen) phase_sources.setdefault(key[0], curve) for panel, visible, sources in ((mag, settings.show_magnitude, mag_sources), @@ -171,7 +158,6 @@ class TraceRenderer: panel.sync_legend(sources if not single else {}) if visible: panel.item.setTitle(title) - panel.view.invertY(False) if active and np.isfinite(x_min) and np.isfinite(x_max): panel.view.setXRange(x_min, x_max, padding=0.02) if settings.show_magnitude: diff --git a/python_app/gui/controllers/snapshot_controller.py b/python_app/gui/controllers/snapshot_controller.py index f9ca1c8..7e2306a 100644 --- a/python_app/gui/controllers/snapshot_controller.py +++ b/python_app/gui/controllers/snapshot_controller.py @@ -109,6 +109,8 @@ class SnapshotController: return self.pipeline.history_changed() self.pipeline.plots.redraw() + # Removing the newest sweep brings an older one, maybe never replayed, into a stopped B-scan. + self.pipeline.schedule_replay() self._log( f"{action}: dropped raw={dropped_raw}, " f"preprocessed={dropped_pre}, results={dropped_results}" diff --git a/python_app/gui/controllers/web_controller.py b/python_app/gui/controllers/web_controller.py index ef4e512..5d7648a 100644 --- a/python_app/gui/controllers/web_controller.py +++ b/python_app/gui/controllers/web_controller.py @@ -12,7 +12,6 @@ import threading import time from typing import TYPE_CHECKING -import pyqtgraph as pg from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget @@ -100,20 +99,13 @@ class WebBridge(QObject): self._frame: dict | None = None self._pending_lock = threading.Lock() self._pending_calls: set[_WebActionCall] = set() - self._closed = self._streaming = self._grabbing = False - self._dirty = True + self._closed = self._streaming = False + self._grabbed_revision: int | None = None # plot revision shown by the last grab; None forces one self._last_png: str | None = None self._last_grab_s = 0.0 self._frame_seq = self._snapshot_errors = 0 self._action_requested.connect(self._run_action) self._streaming_changed.connect(self._set_streaming) - plots = pipeline.plots - plots.stack.currentChanged.connect(self._mark_dirty) - for plot in plots.stack.findChildren(pg.PlotWidget): - scene = plot.scene() - assert scene is not None - scene.changed.connect(self._mark_dirty) - plot.sigDeviceRangeChanged.connect(self._mark_dirty) def start_server(self) -> None: if self._closed or self._server is not None: @@ -295,26 +287,19 @@ class WebBridge(QObject): if not self._closed: self._streaming = enabled if enabled: - self._dirty = True - - def _mark_dirty(self, *_args: object) -> None: - if not self._closed and not self._grabbing: - self._dirty = True + self._grabbed_revision = None def _grab_frame_if_due(self) -> dict | None: - if self._closed or not self._streaming or not self._dirty: + plots = self._pipeline.plots + if self._closed or not self._streaming or plots.revision == self._grabbed_revision: return None now = time.monotonic() if now - self._last_grab_s < _GRAB_INTERVAL_S: return None - self._last_grab_s, self._dirty, self._grabbing = now, False, True - try: - png = self._grab_png() - except Exception: - self._dirty = True - raise - finally: - self._grabbing = False + self._last_grab_s = now + png = self._grab_png() + # Rendering applies pending resizes and lazy auto-ranges; this image already shows them. + self._grabbed_revision = plots.revision if png is None or png == self._last_png: return None self._last_png = png diff --git a/python_app/gui/main.py b/python_app/gui/main.py index fffbc12..3e87393 100644 --- a/python_app/gui/main.py +++ b/python_app/gui/main.py @@ -7,7 +7,6 @@ from pathlib import Path import signal import sys -import pyqtgraph as pg from PyQt6.QtCore import QTimer from PyQt6.QtWidgets import QApplication @@ -54,8 +53,6 @@ def main() -> int: """Run Qt event loop and show main radar control window.""" app = QApplication(sys.argv) apply_light_theme(app) - # PyQtGraph foreground controls axis lines, tick text, labels, and titles. - pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#ffffff") window = AppWindow(PROJECT_ROOT) if env_enabled("RADAR_SYSTEM_HEADLESS"): _install_unix_signal_handlers(app, window) diff --git a/python_app/gui/object_calibration_dialog.py b/python_app/gui/object_calibration_dialog.py index 60445d1..4e5d402 100644 --- a/python_app/gui/object_calibration_dialog.py +++ b/python_app/gui/object_calibration_dialog.py @@ -15,7 +15,6 @@ from PyQt6.QtWidgets import ( ) from python_app.models.dataset_model import SweepCollection -from python_app.gui.theme import PLOT_BACKGROUND class ObjectCalibrationDialog(QDialog): @@ -132,7 +131,6 @@ class ObjectCalibrationDialog(QDialog): self._target_combo = QComboBox(self) layout.addWidget(self._target_combo) self._phase_view = pg.GraphicsLayoutWidget(parent=self) - self._phase_view.setBackground(PLOT_BACKGROUND) self._phase_view.setMinimumHeight(270) self._phase_plots = [] self._phase_images = [] diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index 9a5538a..b9728a6 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -26,7 +26,7 @@ from PyQt6.QtWidgets import ( import numpy as np import pyqtgraph as pg -from python_app.gui.controllers.renderers.common import create_plot +from python_app.gui.controllers.renderers.common import CAPTURED_TRACE_PEN, create_plot from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel from python_app.models.dataset_model import TraceData from python_app.orchestration.preprocess_assets import ( @@ -470,17 +470,9 @@ class PreprocessDialog(QDialog): assert self._amplitude_plot is not None assert self._phase_plot is not None self._amplitude_plot.clear() - self._amplitude_plot.plot( - trace.frequency_hz, - magnitude_db, - pen=pg.mkPen("#4cc9f0", width=1.8), - ) + self._amplitude_plot.plot(trace.frequency_hz, magnitude_db, pen=CAPTURED_TRACE_PEN) self._phase_plot.clear() - self._phase_plot.plot( - trace.frequency_hz, - phase_deg, - pen=pg.mkPen("#f48c06", width=1.8), - ) + self._phase_plot.plot(trace.frequency_hz, phase_deg, pen=CAPTURED_TRACE_PEN) elif self._preview_placeholder is not None: self._preview_placeholder.setText( f"{title}\n" diff --git a/python_app/gui/theme.py b/python_app/gui/theme.py index 733eace..f37620c 100644 --- a/python_app/gui/theme.py +++ b/python_app/gui/theme.py @@ -1,11 +1,14 @@ -"""Application-wide Qt palette and stylesheet configuration.""" +"""Application-wide Qt palette, stylesheet and plot colors.""" from __future__ import annotations +import pyqtgraph as pg from PyQt6.QtGui import QColor, QPalette from PyQt6.QtWidgets import QApplication, QStyleFactory -PLOT_BACKGROUND = "#0f141c" +TEXT_COLOR = "#1f2937" +PLOT_BACKGROUND = "#ffffff" +PLOT_FOREGROUND = TEXT_COLOR # axes, ticks, titles, legends and annotations _LIGHT_STYLESHEET = """ QGroupBox { @@ -82,7 +85,8 @@ QLabel#hintLabel { def apply_light_theme(app: QApplication) -> None: - """Apply a minimal light theme shared by all windows.""" + """Apply a minimal light theme shared by all windows and plots; call before creating them.""" + pg.setConfigOptions(antialias=True, background=PLOT_BACKGROUND, foreground=PLOT_FOREGROUND) app.setStyle(QStyleFactory.create("Fusion")) font = app.font() font.setPointSize(10) @@ -90,14 +94,14 @@ def apply_light_theme(app: QApplication) -> None: palette = QPalette() palette.setColor(QPalette.ColorRole.Window, QColor("#f3f6fb")) - palette.setColor(QPalette.ColorRole.WindowText, QColor("#1f2937")) + palette.setColor(QPalette.ColorRole.WindowText, QColor(TEXT_COLOR)) palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#eef3f8")) palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff")) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#1f2937")) - palette.setColor(QPalette.ColorRole.Text, QColor("#1f2937")) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(TEXT_COLOR)) + palette.setColor(QPalette.ColorRole.Text, QColor(TEXT_COLOR)) palette.setColor(QPalette.ColorRole.Button, QColor("#f8fafc")) - palette.setColor(QPalette.ColorRole.ButtonText, QColor("#1f2937")) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT_COLOR)) palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff")) palette.setColor(QPalette.ColorRole.Link, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6")) diff --git a/python_app/gui/trace_png_export.py b/python_app/gui/trace_png_export.py index b4bf1f7..8a112b3 100644 --- a/python_app/gui/trace_png_export.py +++ b/python_app/gui/trace_png_export.py @@ -8,7 +8,6 @@ preview into off-screen PNG files, letting us persist a graph for every config. from __future__ import annotations -import logging from pathlib import Path import numpy as np @@ -16,13 +15,9 @@ import pyqtgraph as pg from PyQt6.QtCore import QRectF from pyqtgraph.exporters import ImageExporter +from python_app.gui.controllers.renderers.common import CAPTURED_TRACE_PEN from python_app.models.dataset_model import TraceData -from python_app.gui.theme import PLOT_BACKGROUND -logger = logging.getLogger(__name__) - -_MAGNITUDE_PEN = pg.mkPen("#4cc9f0", width=1.8) -_PHASE_PEN = pg.mkPen("#f48c06", width=1.8) _EXPORT_SIZE = (1400, 520) @@ -43,7 +38,6 @@ def export_trace_png( phase_deg = np.degrees(np.angle(samples)) layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE) - layout.setBackground(PLOT_BACKGROUND) try: layout.ci.addLabel(title, row=0, col=0, colspan=2) @@ -51,13 +45,13 @@ def export_trace_png( magnitude_plot.showGrid(x=True, y=True, alpha=0.2) magnitude_plot.setLabel("bottom", "Frequency", units="Hz") magnitude_plot.setLabel("left", "Magnitude", units="dB") - magnitude_plot.plot(trace.frequency_hz, magnitude_db, pen=_MAGNITUDE_PEN) + magnitude_plot.plot(trace.frequency_hz, magnitude_db, pen=CAPTURED_TRACE_PEN) phase_plot = layout.ci.addPlot(row=1, col=1) phase_plot.showGrid(x=True, y=True, alpha=0.2) phase_plot.setLabel("bottom", "Frequency", units="Hz") phase_plot.setLabel("left", "Phase", units="deg") - phase_plot.plot(trace.frequency_hz, phase_deg, pen=_PHASE_PEN) + phase_plot.plot(trace.frequency_hz, phase_deg, pen=CAPTURED_TRACE_PEN) # An off-screen widget never receives a resizeEvent, so its central # GraphicsLayout keeps its small preferred size and both plots would be diff --git a/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py b/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py index 545b0e1..5b50dae 100644 --- a/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py +++ b/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py @@ -68,11 +68,11 @@ class _DeviceCollectionState: """Mutable per-device flag marking that this device's capture has finished. A device finishes when its final requested sweep cycle closes — either every - point of that cycle was delivered, or the free-running hardware sweep wrapped - back to point 0, proving the still-missing points are dropped frames that will - never arrive. The collector thread polls this flag so a gap ends the cycle at - the wrap instead of stalling until the no-progress timeout fires; the missing - points are zero-filled when the S-parameters are computed. + point of that cycle was delivered, or the point index stopped rising because the + free-running hardware sweep started over, proving the still-missing points are + dropped frames that will never arrive. The collector thread polls this flag so a + gap ends the cycle there instead of stalling until the no-progress timeout fires; + the missing points are zero-filled when the S-parameters are computed. """ __slots__ = ("last_cycle_closed", "capture_start_ns", "capture_end_ns") @@ -449,8 +449,8 @@ def collect_complete_running_sweep_cycle( """Wrap a cycle-aware handler with cross-device cycle tracking. Returns a per-datapoint handler that anchors cycle 0 on the first - ``point_index == 0`` seen, closes on the next point-index wrap, and reports whether each - datapoint was consumed. + ``point_index == 0`` seen, closes once the point index stops rising (the next + sweep began), and reports whether each datapoint was consumed. """ # In steady state the controller leaves the hardware sweep FREE-RUNNING # between collections (the unchanged-config fast path only drains the host @@ -468,6 +468,8 @@ def collect_complete_running_sweep_cycle( # stale point 0 could still have been in flight; a large count means the # remainder of the in-progress sweep was safely skipped. "pre_anchor_skipped": 0, + # Newest point index of the anchored cycle; indices only rise within a sweep. + "last_point_index": 0, } def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool: @@ -504,10 +506,12 @@ def collect_complete_running_sweep_cycle( cycle_aware_handler(parsed_datapoint) return True - if current_point_index == 0: - # A second point 0 closes this cycle; absent points are now losses. + if current_point_index <= cycle_tracking_state["last_point_index"]: + # The next sweep began: this closes the cycle even when its point 0 was + # lost, so its points never overwrite this one. Absent points are losses. device_state.last_cycle_closed = True return False + cycle_tracking_state["last_point_index"] = current_point_index cycle_aware_handler(parsed_datapoint) device_state.capture_end_ns = received_ns return True diff --git a/python_app/orchestration/shm/ring_reader.py b/python_app/orchestration/shm/ring_reader.py index d91a059..53ec716 100644 --- a/python_app/orchestration/shm/ring_reader.py +++ b/python_app/orchestration/shm/ring_reader.py @@ -49,52 +49,50 @@ class ShmRingReader: logger.debug("Closed SHM ring reader %s", self.name) def pop_payload(self) -> bytes | None: - """Read next payload from ring, or `None` if no unread payload exists.""" - write_seq = self._read_u64(24) - read_seq = self._read_u64(32) - if read_seq >= write_seq: - return None + """Read the next unread payload, or `None` once none remains.""" + while True: + write_seq = self._read_u64(24) + read_seq = self._read_u64(32) + if read_seq >= write_seq: + return None + # As in the C++ pop, a lapped, torn or corrupt slot is skipped with the backlog + # behind the newest payload, which is read next (latest-wins). + resync = max(read_seq + 1, write_seq - 1) - index = read_seq % self.capacity - slot_offset = HEADER.size + index * (SLOT_HEADER_SIZE + self.slot_size_bytes) + index = read_seq % self.capacity + slot_offset = HEADER.size + index * (SLOT_HEADER_SIZE + self.slot_size_bytes) - # Seqlock read mirroring the C++ pop: a slot is valid for read_seq R only if - # its sequence equals R+1 and is unchanged across the payload copy (i.e. the - # producer did not overwrite this slot mid-copy). Sequence and payload_size - # are read first; the slot is only accepted after the re-read confirms both. - sequence = self._read_u64(slot_offset + 8) - if sequence != read_seq + 1: - # Producer overwrote this slot before we read it. Resync to latest. - logger.debug("Ring %s: slot lapped before read, resyncing to write_seq=%d", self.name, write_seq) - atomics.advance(self._mmap, 32, write_seq) - return None + # Seqlock read mirroring the C++ pop: a slot is valid for read_seq R only if + # its sequence equals R+1 and is unchanged across the payload copy (i.e. the + # producer did not overwrite this slot mid-copy). Sequence and payload_size + # are read first; the slot is only accepted after the re-read confirms both. + if self._read_u64(slot_offset + 8) != read_seq + 1: + logger.debug("Ring %s: slot lapped before read, resyncing to %d", self.name, resync) + atomics.advance(self._mmap, 32, resync) + continue - payload_size = self._read_u32(slot_offset) - # Bound payload_size against the slot before slicing so a torn/garbage size - # can never read out of the slot region; resync and skip on violation. - if payload_size > self.slot_size_bytes: - logger.debug( - "Ring %s: payload_size %d exceeds slot %d, resyncing", - self.name, - payload_size, - self.slot_size_bytes, - ) - atomics.advance(self._mmap, 32, write_seq) - return None + payload_size = self._read_u32(slot_offset) + # Bound payload_size against the slot before slicing so a torn/garbage size + # can never read out of the slot region. + if payload_size > self.slot_size_bytes: + logger.debug("Ring %s: payload_size %d exceeds slot %d, resyncing to %d", + self.name, payload_size, self.slot_size_bytes, resync) + atomics.advance(self._mmap, 32, resync) + continue - payload_offset = slot_offset + SLOT_HEADER_SIZE - payload = bytes(self._mmap[payload_offset : payload_offset + payload_size]) - atomics.fence() + payload_offset = slot_offset + SLOT_HEADER_SIZE + payload = bytes(self._mmap[payload_offset : payload_offset + payload_size]) + atomics.fence() - # Re-read the slot sequence after the copy; if it changed, the producer - # overwrote this slot mid-copy and the payload is torn — discard and resync. - if self._read_u64(slot_offset + 8) != read_seq + 1: - logger.debug("Ring %s: slot overwritten mid-copy, discarding torn payload", self.name) - atomics.advance(self._mmap, 32, write_seq) - return None + # Re-read the slot sequence after the copy; if it changed, the producer + # overwrote this slot mid-copy and the payload is torn. + if self._read_u64(slot_offset + 8) != read_seq + 1: + logger.debug("Ring %s: slot overwritten mid-copy, resyncing to %d", self.name, resync) + atomics.advance(self._mmap, 32, resync) + continue - atomics.advance(self._mmap, 32, read_seq + 1) - return payload + atomics.advance(self._mmap, 32, read_seq + 1) + return payload def pop_raw_collection(self) -> SweepCollection | None: """Read next raw collection from ring.""" diff --git a/python_app/tests/test_bscan_replay.py b/python_app/tests/test_bscan_replay.py index 9605aaa..2ca3934 100644 --- a/python_app/tests/test_bscan_replay.py +++ b/python_app/tests/test_bscan_replay.py @@ -140,6 +140,25 @@ class ReplayTest(unittest.TestCase): self.assertIn(unrelated, self.history.results) self.writer_factory.assert_called_once_with(self.ring.name, self.ring.capacity, self.ring.slot_size_bytes) + def test_unreturned_sweeps_are_left_out_instead_of_mixing_settings(self) -> None: + sweeps = [_sweep(cid) for cid in range(1, 7)] + self.history.preprocessed.extend(sweeps) + self.history.results.extend(replace(_result_of(sweep), settings_revision=3) for sweep in sweeps) + + def push_dropping_two(payload: bytes) -> bool: + accepted = self.processor.push(payload) + if self.processor.received[-1].collection_id in {2, 5}: + self.processor.published.pop() + return accepted + + self.writer.push = push_dropping_two + with patch("python_app.gui.controllers.bscan_replay._REPLAY_CHUNK_TIMEOUT_S", .01): + self.assertTrue(self.run_replay()) + source = self.history.bscan_source() + self.assertEqual([item.collection_id for item in source], [1, 3, 4, 6]) + self.assertTrue(all(item.settings_revision == 7 for item in source)) + self.assertEqual(self.warnings[-1], "B-scan replay recovered 4 of 6 re-sent sweeps; the image leaves the others out.") + def test_refuses_without_reader_or_active_ring_geometry(self) -> None: self.history.preprocessed.append(_sweep(1)) self.assertFalse(self.replay.run(window=1, revision=7, ring=self.ring, reader=None, diff --git a/python_app/tests/test_librevna_multidevice.py b/python_app/tests/test_librevna_multidevice.py index 9b347f3..7a5aa8e 100644 --- a/python_app/tests/test_librevna_multidevice.py +++ b/python_app/tests/test_librevna_multidevice.py @@ -160,6 +160,36 @@ class CaptureWindowTest(unittest.TestCase): np.testing.assert_array_equal(trace.s21, np.full(5, 0.5)) +class CycleClosureTest(unittest.TestCase): + def test_next_sweep_without_its_point_zero_still_closes_the_cycle(self) -> None: + # A point of this sweep and point 0 of the next are lost: the next sweep's point 1 + # must close the cycle instead of overwriting this sweep's point 1. + points = 40 + configuration = SweepConfiguration(1_000_000, 2_000_000, points, 1000, -10.0) + frames = [*((point, 1 + 0j) for point in range(points) if point != 3), + *((point, 7 + 0j) for point in range(1, points))] + + def connection(serial: str) -> Mock: + sequence = iter(frames) + + def receive(*, timeout_seconds): + point, measured = next(sequence) + values = [(0x13, 2 + 0j), (0x01, measured), (0x02, measured), + (0x33, 2 + 0j), (0x21, measured), (0x22, measured)] + return 27, _datapoint_frame(1_000_000 + point * 25_000, point, values)[4:-4] + + return Mock(serial_number=serial, receive_packet=receive) + + result = collect_complete_running_sweep_cycle( + master_device_connection=connection("M"), slave_device_connections=[connection("A"), connection("B")], + active_sweep_configuration=configuration, master_stimulus_ports=(1, 2), + ) + expected = np.full(points, .5) + expected[3] = 0 # the lost point is zero-filled + for trace in result.s_parameters.values(): + np.testing.assert_array_equal(trace, expected) + + class CrossDeviceCycleSynchronizerTest(unittest.TestCase): """N1: cycle-0 anchors are accepted only when all devices saw the same wrap.""" diff --git a/python_app/tests/test_processor_history.py b/python_app/tests/test_processor_history.py index e2a4070..17fe4a0 100644 --- a/python_app/tests/test_processor_history.py +++ b/python_app/tests/test_processor_history.py @@ -209,6 +209,44 @@ class FedBackSweepsTest(_ProcessorTestCase): self._live_writer.write(self._live) self.assertEqual(self._published_ids(_SWEEPS), expected) + def test_sweep_failing_to_recompute_does_not_stall_new_input(self) -> None: + self._live.processor_mode = "gpr" + self._live.gpr_input_positions = self._live.gpr_output_positions = [0] + self._live.gpr_background_subtract_enabled = False + self._live.tx_geometry = [{"output_pos": 0, "x_m": 0.0, "y_m": 0.0, "z_m": 0.0}] + self._live.rx_geometry = [{"input_pos": 0, "x_m": 0.0, "y_m": 0.0, "z_m": 0.0}] + self._live_writer.write(self._live) + start = time.monotonic_ns() + self._push([_sweep(1, start)]) + self._result_for_revision(0, 1) + failing = _sweep(2, start + 1_000_000) + failing.traces[0].capture_end_ns = failing.traces[0].capture_start_ns # GPR rejects an empty trace window + self._push([failing]) + deadline = time.monotonic() + _TIMEOUT_S + while self._input._read_u64(32) != self._input._read_u64(24): # now the newest retained sweep + self.assertLess(time.monotonic(), deadline, self._stderr_path.read_text()) + time.sleep(.01) + # Both arrive while the processor is stopped past its 100 ms live-file check, so on + # resuming it recomputes that failing sweep before it takes the next one. + self._processor.send_signal(signal.SIGSTOP) + os.waitpid(self._processor.pid, os.WUNTRACED) + try: + self._live.settings_revision = 1 + self._live_writer.write(self._live) + self._push([_sweep(3, start + 2_000_000)]) + time.sleep(.2) + finally: + self._processor.send_signal(signal.SIGCONT) + self._result_for_revision(1, 3) + + def test_bscan_band_without_two_sweep_points_gives_an_empty_profile(self) -> None: + self._live.bscan_start_freq_mhz, self._live.bscan_stop_freq_mhz = 7000.0, 8000.0 # sweep is 1-6 GHz + self._live.settings_revision = 1 + self._live_writer.write(self._live) + self._push([_sweep(1, time.monotonic_ns())]) + payload = self._result_for_revision(1, 1).blocks[0].payloads[0] + self.assertEqual((payload.processing_name, payload.frequency_hz.size, payload.trace.size), ("bscan", 0, 0)) + def test_live_geometry_recomputes_retained_gpr_without_restarting(self) -> None: self._live.processor_mode = "gpr" self._live.gpr_input_positions = [0] diff --git a/python_app/tests/test_result_display.py b/python_app/tests/test_result_display.py new file mode 100644 index 0000000..718de15 --- /dev/null +++ b/python_app/tests/test_result_display.py @@ -0,0 +1,79 @@ +"""The display shows the newest acquisition: after polls, Stop drains and settings changes.""" + +from __future__ import annotations + +import os +import unittest +from unittest import mock + +import numpy as np + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PyQt6.QtWidgets import QApplication # noqa: E402 + +from python_app.gui.controllers.renderers.trace import TraceRenderer # noqa: E402 +from python_app.models.dataset_model import ComboKey, ResultBlock, ResultCollection, ResultPayload # noqa: E402 +from python_app.models.gui_profile_schema import GuiPassThroughStateModel # noqa: E402 +from python_app.tests.test_reader_recovery import pipeline_fixture # noqa: E402 + + +def _result(collection_id: int, capture_start_ns: int, revision: int = 0) -> ResultCollection: + return ResultCollection(collection_id, capture_start_ns + 1, settings_revision=revision, + capture_start_ns=capture_start_ns, capture_end_ns=capture_start_ns + 1) + + +class NewestResultDisplayTest(unittest.TestCase): + def setUp(self) -> None: + self.pipeline, _supervisor = pipeline_fixture(self) + self.pipeline.pump_events = mock.Mock() + draw = mock.patch.object(self.pipeline.plots, "draw") + self.draw = draw.start() + self.addCleanup(draw.stop) + + def read(self, *results: ResultCollection | None) -> None: + reader = mock.Mock() + reader.pop_result_collection.side_effect = [*results, *[None] * 10] + self.pipeline._result_reader = reader + + def test_poll_shows_the_newest_acquisition_rather_than_the_last_one_read(self) -> None: + newest = _result(2, 200) + self.read(newest, _result(1, 100)) # a re-fed earlier sweep arrives after the newest + self.pipeline.poll() + self.draw.assert_called_once_with(newest) + + def test_results_drained_by_stop_are_shown_once_stopped(self) -> None: + last = _result(2, 200) + self.read(_result(1, 100), last) + self.pipeline.stop() + self.draw.assert_not_called() + self.pipeline.poll() + self.pipeline.poll() + self.draw.assert_called_once_with(last) + + def test_revision_drain_waits_past_quiet_polls_for_the_published_revision(self) -> None: + stale, fresh = _result(1, 100, revision=1), _result(1, 100, revision=2) + for revision, expected in ((None, stale), (2, fresh)): + with self.subTest(revision=revision): + self.pipeline.history.results.clear() + self.read(stale, None, None, None, fresh) + self.pipeline.drain_results(timeout_s=60, poll_s=0, revision=revision) + self.assertIs(self.pipeline.history.results[-1], expected) + + +class TraceViewTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.app = QApplication.instance() or QApplication([]) + + def test_draws_only_pass_through_payloads(self) -> None: + renderer = TraceRenderer(mock.Mock()) + self.addCleanup(renderer.widget.close) + payloads = [ResultPayload(name, 1, frequency_hz=np.array([1e9, 2e9]), trace=np.ones(2, np.complex64)) + for name in ("bscan", "pass_through")] + collection = ResultCollection(1, 1, blocks=[ResultBlock(ComboKey(0, 0), payloads)]) + self.assertTrue(renderer.draw(collection, GuiPassThroughStateModel())) + self.assertEqual([name for _combo, _index, name in renderer.magnitude.curves], ["pass_through"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/tests/test_review_gui_regressions.py b/python_app/tests/test_review_gui_regressions.py index 0d05f16..42a94ef 100644 --- a/python_app/tests/test_review_gui_regressions.py +++ b/python_app/tests/test_review_gui_regressions.py @@ -129,8 +129,9 @@ class ReviewGuiRegressions(unittest.TestCase): pipeline, supervisor = pipeline_fixture(self) pipeline.should_run = True supervisor.pids.return_value = dict.fromkeys(("acquisition", "data_preprocessor", "data_processor"), 1) - with mock.patch.object(pipeline, "read_results", return_value=ResultCollection(1, 2)), \ - mock.patch.object(pipeline.plots, "draw", side_effect=ValueError("bad display")), \ + results = iter([ResultCollection(1, 2), None, ResultCollection(2, 3)]) + pipeline._result_reader = mock.Mock(pop_result_collection=lambda: next(results, None)) + with mock.patch.object(pipeline.plots, "draw", side_effect=ValueError("bad display")), \ mock.patch.object(pipeline, "stop_all") as stop, \ self.assertLogs("python_app.gui.controllers.pipeline_controller", "ERROR") as logs: pipeline.poll() diff --git a/python_app/tests/test_sar3d_plot.py b/python_app/tests/test_sar3d_plot.py index 42cf572..cc868eb 100644 --- a/python_app/tests/test_sar3d_plot.py +++ b/python_app/tests/test_sar3d_plot.py @@ -234,7 +234,9 @@ class Sar3dIntegrationTest(unittest.TestCase): (_collection(_status(2, "fewer than two frequency points")), "error"), (_collection(_status(1, "No signal in selected depth range")), "no signal"), (_projections(), "ready — CUDA")): - self.view._pipeline.drain_results = mock.Mock(return_value=result) + # Like the real drain, the recomputed result replaces its sweep in history. + self.view._pipeline.drain_results = mock.Mock( + side_effect=lambda result=result, **_timing: self.view._history.record_result(result)) self.view._live.changed() self.assertIn(expected, self.renderer.status_label.text()) self.view._log.show_exception.assert_not_called() diff --git a/python_app/tests/test_shm_ipc.py b/python_app/tests/test_shm_ipc.py index d0635d3..dccc830 100644 --- a/python_app/tests/test_shm_ipc.py +++ b/python_app/tests/test_shm_ipc.py @@ -34,6 +34,7 @@ from python_app.orchestration.shm.decoder import ( decode_result_collection, decode_trace_collection, ) +from python_app.orchestration.shm.ring_layout import HEADER from python_app.orchestration.shm.ring_reader import ShmRingReader from python_app.orchestration.shm.ring_writer import ShmRingWriter from python_app.storage.npz.serialize import serialize_trace_collection @@ -304,8 +305,18 @@ class RingOverflowTest(_RingTestCase): writer.push(b"new") with mock.patch.object(atomics, "fence", side_effect=overwrite_after_copy): - self.assertIsNone(reader.pop_payload()) - self.assertEqual(reader.pop_payload(), b"new") + self.assertEqual(reader.pop_payload(), b"new") # the torn copy is never returned + self.assertIsNone(reader.pop_payload()) + + def test_lapped_reader_continues_with_the_newest_payload(self) -> None: + name = self._ring_name() + writer = self._writer(name, capacity=4, slot_size=64) + reader = self._reader(name) + for i in range(3): + writer.push(f"f{i}".encode()) + writer._write_u64(HEADER.size + 8, 0) # the producer is overwriting the unread f0 + self.assertEqual(reader.pop_payload(), b"f2") + self.assertIsNone(reader.pop_payload()) def test_reader_cannot_rewind_cursor_after_producer_laps_during_commit(self) -> None: from python_app.orchestration.shm import atomics diff --git a/python_app/tests/test_web_frame_streaming.py b/python_app/tests/test_web_frame_streaming.py index e5cfc0f..6310d65 100644 --- a/python_app/tests/test_web_frame_streaming.py +++ b/python_app/tests/test_web_frame_streaming.py @@ -1,16 +1,54 @@ - -from contextlib import ExitStack -"""Plot capture follows client demand and actual Qt scene changes.""" +"""Plot capture follows client demand and plot changes; redraws leave no stale pixels.""" from collections import deque +from contextlib import ExitStack import unittest from unittest import mock import numpy as np -from PyQt6.QtCore import QSignalBlocker -from PyQt6.QtWidgets import QApplication, QComboBox +import pyqtgraph as pg +from PyQt6.QtCore import QPoint, QSignalBlocker +from PyQt6.QtGui import QImage +from PyQt6.QtWidgets import QApplication, QCheckBox -from python_app.tests.gui_test_support import isolated_window +from python_app.gui.controllers.renderers.sar3d import SAR3D_VIEWS +from python_app.models.dataset_model import ComboKey, ResultBlock, ResultCollection, ResultPayload, TraceData +from python_app.tests.gui_test_support import isolated_window, widget + + +def _phase_result(phase_deg: np.ndarray) -> ResultCollection: + trace = (0.5 * np.exp(1j * np.radians(phase_deg))).astype(np.complex64) + payload = ResultPayload("pass_through", 1, frequency_hz=np.linspace(1e9, 2e9, phase_deg.size), trace=trace) + return ResultCollection(1, 1, blocks=[ResultBlock(ComboKey(0, 0), [payload])]) + + +def _gpr_result(revision: int, point: tuple[float, float], size: int) -> ResultCollection: + image = np.linspace(0, 1, size * size, dtype=np.float32).reshape(size, size) + return ResultCollection(1, 1, settings_revision=revision, collection_payloads=[ + ResultPayload("gpr_accumulator", 3, image=image, + image_x_axis=np.linspace(-1.5, 1.5, size), image_y_axis=np.linspace(1, 12, size)), + ResultPayload("gpr_points", 4, table=np.array([[*point, 0.9]], dtype=np.float32)), + ]) + + +def _sar3d_result(revision: int, peak: tuple[float, float, float]) -> ResultCollection: + x, y, z = np.linspace(-1, 1, 9), np.linspace(-.5, .5, 5), np.linspace(1, 3, 9) + payloads = [ResultPayload(name, 3, image_x_axis=xx, image_y_axis=yy, + image=np.full((yy.size, xx.size), .5, dtype=np.float32)) + for name, (xx, yy) in zip(SAR3D_VIEWS, ((x, y), (z, y), (x, z), (x, y)))] + return ResultCollection(1, 1, settings_revision=revision, collection_payloads=[ + *payloads, + ResultPayload("sar3d_status", 4, table=np.zeros((1, 1), dtype=np.float32)), + ResultPayload("sar3d_oblique_basis", 4, table=np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float32)), + ResultPayload("sar3d_peak", 4, table=np.array([[1, *peak]], dtype=np.float32)), + ]) + + +def _bscan_result(collection_id: int, depth_m: np.ndarray) -> ResultCollection: + trace = np.full(depth_m.size, collection_id, dtype=np.complex64) + payload = ResultPayload("bscan", 1, frequency_hz=depth_m.astype(np.float32), trace=trace) + return ResultCollection(collection_id, collection_id, capture_start_ns=collection_id, + capture_end_ns=collection_id + 1, blocks=[ResultBlock(ComboKey(0, 0), [payload])]) class WebPlotCaptureTest(unittest.TestCase): @@ -37,49 +75,53 @@ class WebPlotCaptureTest(unittest.TestCase): self.web._last_grab_s = 0 self.web._grab_frame_if_due() - def test_no_clients_never_grabs_and_first_client_gets_current_plot(self) -> None: - self.w._plots.bscan.widget.plot([0, 1], [1, 2]) - self.settle() - self.grab.assert_not_called() - controller = self.web + def show_mode(self, name: str) -> None: mode = self.w._view.processing.mode with QSignalBlocker(mode): - mode.setCurrentText("bscan") - self.w._live.active_mode = "bscan" + mode.setCurrentText(name) + self.w._live.active_mode = name + self.w._plots.set_mode(name) + + def test_no_clients_never_grabs_and_first_client_gets_current_plot(self) -> None: + trace = TraceData(ComboKey(0, 0), np.array([1., 2.]), np.array([.1 + 0j, .2 + 0j])) + self.w._plots.draw_trace(trace, "Captured") + self.settle() + self.grab.assert_not_called() + self.show_mode("bscan") self.w._plots.bscan.history_by_combo = {(2, 3): deque([np.array([1., 2.])])} self.w._plots.sync_display_combos() self.web.refresh_snapshot() - self.assertEqual(controller.status()["display_combo"], + self.assertEqual(self.web.status()["display_combo"], {"enabled": True, "input": 2, "output": 3, "options": [(2, 3)]}) self.grab.assert_not_called() - controller.set_frame_streaming(True) + self.web.set_frame_streaming(True) self.settle() self.assertGreater(self.grab.call_count, 0) self.assertIsNotNone(self.web._last_png) calls = self.grab.call_count self.settle() - self.assertEqual(self.grab.call_count, calls) # grab does not keep itself dirty - controller.set_frame_streaming(False) - self.w._plots.bscan.widget.clear() + self.assertEqual(self.grab.call_count, calls) # a grab does not trigger another one + self.web.set_frame_streaming(False) + self.w._plots.clear() self.settle() self.assertEqual(self.grab.call_count, calls) - def test_data_clear_zoom_resize_and_sar_grid_trigger_capture_after_idle(self) -> None: + def test_drawing_clearing_zoom_resize_and_page_switch_trigger_capture_after_idle(self) -> None: + plots = self.w._plots + self.show_mode("pass_through") self.web.set_frame_streaming(True) - plot = self.w._plots.bscan.widget - item = plot.getPlotItem() - assert item is not None - view = item.getViewBox() - self.w._plots.stack.setCurrentWidget(plot) self.settle() + view = plots.trace.magnitude.view actions = [ - lambda: plot.plot([0, 1, 2], [1, 4, 2]), - lambda: view.setXRange(0.25, 1.5, padding=0), - plot.clear, + lambda: plots.draw(_phase_result(np.full(50, 30.0))), + lambda: view.setXRange(1.2e9, 1.5e9, padding=0), + plots.redraw, + plots.clear, lambda: self.w.resize(self.w.width() + 50, self.w.height() + 50), - lambda: self.w._plots.stack.setCurrentWidget(self.w._plots.sar3d.widget), + lambda: plots.set_mode("sar3d"), + lambda: plots.draw_trace(TraceData(ComboKey(0, 0), np.array([1., 2.]), np.array([.1 + 0j, .2 + 0j])), + "Captured"), ] - actions.extend(lambda plot=plot: plot.plot([0, 1], [1, 3]) for plot in self.w._plots.sar3d.plots.values()) for action in actions: with self.subTest(action=action): calls = self.grab.call_count @@ -90,18 +132,88 @@ class WebPlotCaptureTest(unittest.TestCase): self.settle() self.assertEqual(self.grab.call_count, calls) - def test_throttle_keeps_dirty_and_ignores_changes_emitted_during_grab(self) -> None: + def test_throttle_defers_a_change_and_a_grab_absorbs_changes_it_renders(self) -> None: + plots = self.w._plots self.web.set_frame_streaming(True) self.settle() - self.web._mark_dirty() + self.grab.reset_mock() + plots.clear() self.web._last_grab_s = 100 with mock.patch("python_app.gui.controllers.web_controller.time.monotonic", return_value=100.1): self.assertIsNone(self.web._grab_frame_if_due()) - self.assertTrue(self.web._dirty) - self.grab.side_effect = lambda: (self.web._mark_dirty(), "changed-png")[1] + self.grab.assert_not_called() + self.grab.side_effect = lambda: (plots.clear(), "changed-png")[1] with mock.patch("python_app.gui.controllers.web_controller.time.monotonic", return_value=100.3): self.assertIsNotNone(self.web._grab_frame_if_due()) - self.assertFalse(self.web._dirty) + self.assertEqual(self.web._grabbed_revision, plots.revision) + with mock.patch("python_app.gui.controllers.web_controller.time.monotonic", return_value=100.6): + self.assertIsNone(self.web._grab_frame_if_due()) + self.assertEqual(self.grab.call_count, 1) + + def test_failed_grab_is_retried(self) -> None: + plots = self.w._plots + self.web.set_frame_streaming(True) + self.settle() + plots.clear() + self.grab.reset_mock(side_effect=True) + self.grab.side_effect = RuntimeError("render failed") + self.web._last_grab_s = 0 + with self.assertRaises(RuntimeError): + self.web._grab_frame_if_due() + self.assertNotEqual(self.web._grabbed_revision, plots.revision) + self.grab.side_effect = None + self.settle() + self.assertEqual(self.grab.call_count, 2) + self.assertEqual(self.web._grabbed_revision, plots.revision) + + def assert_window_shows_fresh_render(self) -> None: + screen, page = QApplication.primaryScreen(), self.w._plots.stack.currentWidget() + assert screen is not None and page is not None + for plot in [page] if isinstance(page, pg.PlotWidget) else page.findChildren(pg.PlotWidget): + if plot.isVisible(): + origin = plot.mapTo(self.w, QPoint()) + shown = screen.grabWindow(self.w.winId(), origin.x(), origin.y(), plot.width(), plot.height()) + self.assertEqual(shown.toImage().convertToFormat(QImage.Format.Format_RGB32), + plot.grab().toImage().convertToFormat(QImage.Format.Format_RGB32)) + + def test_redrawn_views_leave_no_stale_pixels_on_screen(self) -> None: + # Qt repaints only changed regions, yet the window must equal a fresh render: once + # a phase redrawn from 100 to 0 deg left the old curve visible where none was drawn. + plots, history = self.w._plots, self.w._history + for plot in plots.stack.findChildren(pg.PlotWidget): + scene = plot.scene() + assert scene is not None + self.assertEqual(scene.receivers(scene.changed), 0) # a receiver selects that update path + phase = widget(self.w._view.processing, "_show_phase_checkbox", QCheckBox) + self.addCleanup(phase.setChecked, phase.isChecked()) + phase.setChecked(True) + self.web.set_frame_streaming(True) + revision = plots.required_revision or 0 + later_phase = np.zeros(400) + later_phase[200:] = np.nan + + def bscan(count: int, depth_m: np.ndarray) -> bool: + history.results.clear() + history.results.extend(_bscan_result(index, depth_m) for index in range(1, count + 1)) + return plots.draw_bscan() + + frames = { + "pass_through": (lambda: plots.draw(_phase_result(np.full(400, 100.0))), + lambda: plots.draw(_phase_result(later_phase))), + "gpr": (lambda: plots.draw(_gpr_result(revision, (-1.0, 4.0), 20)), + lambda: plots.draw(_gpr_result(revision, (1.0, 9.0), 8))), + "sar3d": (lambda: plots.draw(_sar3d_result(revision, (.6, .3, 2.6))), + lambda: plots.draw(_sar3d_result(revision, (-.6, -.3, 1.4)))), + "bscan": (lambda: bscan(6, np.linspace(.5, 3., 12)), lambda: bscan(3, np.linspace(.5, 2., 4))), + } + self.addCleanup(history.results.clear) + for mode, (first, second) in frames.items(): + with self.subTest(mode=mode): + self.show_mode(mode) + for draw in (first, second): + self.assertTrue(draw()) + self.settle() + self.assert_window_shows_fresh_render() if __name__ == "__main__": diff --git a/python_app/webui/static/style.css b/python_app/webui/static/style.css index e481ff7..9a30ea4 100644 --- a/python_app/webui/static/style.css +++ b/python_app/webui/static/style.css @@ -122,7 +122,7 @@ body { min-height: 0; border: 1px solid var(--border); border-radius: 8px; - background: #0f141c; /* matches the pyqtgraph plot background behind letterboxing */ + background: #ffffff; /* PLOT_BACKGROUND in python_app/gui/theme.py, shown around the letterboxed plot */ overflow: hidden; } #plot { display: block; width: 100%; height: 100%; object-fit: contain; }