fix graph and white background added

This commit is contained in:
Ayzen
2026-10-15 14:46:06 +03:00
parent 5102ed751c
commit 9a9b8ce5c7
35 changed files with 730 additions and 463 deletions
+14 -8
View File
@@ -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 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 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 never blocks a producer — the rings are latest-wins: a full ring overwrites its oldest
skips overwritten frames and continues from the retained tail: 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, 1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps,
and publish raw S21 traces. Both single and multi-device LibreVNA use 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 ## Processing modes
- **pass_through** — magnitude and phase of S21 for each combo. - **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`, - **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; 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). 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 (FWHM, at least one voxel). This measures the imaged response rather than the
reflector's physical dimensions. 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 **Actions** buttons while the settings sections scroll; the settings panel and
runtime log can be collapsed with their arrow buttons. SAR3D status, peak runtime log can be collapsed with their arrow buttons. SAR3D status, peak
coordinates and level appear above the projections. 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 `HistoryController` owns retained acquisitions; `PlotController` selects the view
and supplies independent B-scan, GPR, trace and SAR renderers with typed settings. 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 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 processes. Every plot change passes through `PlotController`, which counts changes
keys and the requested settings revision, and cancels when that run changes. With of the shown image for the browser stream. The display shows the newest acquisition,
no retained preprocessed sweeps (including disabled taps), the processor recomputes including results read while stopping. Stopped B-scan replay uses the last run's
its own retained history. Capture previews preserve the selected processing view. 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 `PipelineController` owns processes, ring readers, polling/recovery, Single Capture
and replay. Its pause prevents recovery from starting a producer over a hardware 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. capture; Stop or Close cancels any pending resumption, including during a drain.
@@ -29,7 +29,7 @@ class ShmRing {
// Pushes payload with overwrite-oldest policy on overflow. // Pushes payload with overwrite-oldest policy on overflow.
[[nodiscard]] auto push(std::span<const std::uint8_t> payload) -> bool; [[nodiscard]] auto push(std::span<const std::uint8_t> payload) -> bool;
// Pops the next payload if available. // Pops the next unread payload; false only when none remains.
[[nodiscard]] auto pop(std::vector<std::uint8_t>& payload) -> bool; [[nodiscard]] auto pop(std::vector<std::uint8_t>& payload) -> bool;
private: private:
@@ -1,5 +1,6 @@
#include "shm_ring.hpp" #include "shm_ring.hpp"
#include <algorithm>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <cerrno> #include <cerrno>
@@ -220,41 +221,43 @@ auto ShmRing::pop(std::vector<std::uint8_t>& payload) -> bool {
throw std::runtime_error("Ring is not open"); throw std::runtime_error("Ring is not open");
} }
const auto read_seq = header_->read_seq.load(std::memory_order_relaxed); while (true) {
const auto write_seq = header_->write_seq.load(std::memory_order_acquire); const auto read_seq = header_->read_seq.load(std::memory_order_relaxed);
if (read_seq >= write_seq) { const auto write_seq = header_->write_seq.load(std::memory_order_acquire);
return false; 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) { void ShmRing::advance_read_seq(std::uint64_t target) {
@@ -38,8 +38,11 @@ class DataProcessor {
const ProcessingLiveConfig& live_config const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection; ) -> 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 resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
[[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool; [[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 // Speed streamed by a locator client (`vlc`), which then replaces
// `gpr_speed_m_s`; nullopt when `ignore_socket_speed` is set or no fresh // `gpr_speed_m_s`; nullopt when `ignore_socket_speed` is set or no fresh
@@ -76,9 +76,8 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::uint64_t last_replayed_revision = live_config_loader_.revision(); std::uint64_t last_replayed_revision = live_config_loader_.revision();
runtime::CollectionErrors errors; runtime::CollectionErrors errors;
std::uint64_t oversized_results = 0; std::uint64_t oversized_results = 0;
// track the socket-fed speed used for the last reprocess so a change // The socket-fed speed used by the last reprocess: in the modes that use the speed, a
// arriving without a live-config revision bump still triggers a reprocess of // change without a live-config revision still recomputes the current result.
// the current result (gated below by reprocess_current_result).
std::optional<double> last_reprocessed_socket_speed = std::nullopt; std::optional<double> last_reprocessed_socket_speed = std::nullopt;
while (!stop_requested.load(std::memory_order_relaxed)) { while (!stop_requested.load(std::memory_order_relaxed)) {
@@ -97,7 +96,8 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
} }
auto& processor = resolve_processor(live_config); auto& processor = resolve_processor(live_config);
const auto history_limit = processing_history_limit(config_, 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) { const auto was_removed = [&](const ipc::PreprocessedCollection& collection) {
if (live_config.history_clear_before_ns && if (live_config.history_clear_before_ns &&
@@ -111,39 +111,36 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
}); });
}; };
if (live_revision != last_replayed_revision || socket_speed_dirty) { 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); std::erase_if(preprocessed_history, was_removed);
if (preprocessed_history.size() > history_limit) { if (preprocessed_history.size() > history_limit) {
preprocessed_history.erase(preprocessed_history.begin(), preprocessed_history.end() - history_limit); preprocessed_history.erase(preprocessed_history.begin(), preprocessed_history.end() - history_limit);
} }
if (!live_config.reprocess_current_result) { // The GUI turns this off while it re-feeds the sweeps of a stopped B-scan itself.
// Socket-fed speed updates should affect only future preprocessed collections, if (live_config.reprocess_current_result && !preprocessed_history.empty()) {
// not replay the current history entry. const auto newest = preprocessed_history.size() - 1U;
} else if (should_replay_entire_history(live_config)) { const auto first = should_replay_entire_history(live_config) ? std::size_t{0} : newest;
for (std::size_t index = 0; index < preprocessed_history.size(); ++index) { for (auto index = first; index <= newest; ++index) {
const auto replay_result = process_collection( try {
preprocessed_history[index], const auto replay_result = process_collection(
std::span<const ipc::PreprocessedCollection>(preprocessed_history.data(), index), preprocessed_history[index],
processor, std::span<const ipc::PreprocessedCollection>(preprocessed_history.data(), index),
live_config processor,
); live_config
publish_result_collection(replay_result, results_ring_, oversized_results); );
publish_locator(replay_result); 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<const ipc::PreprocessedCollection>(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)) { if (preprocessed_ring_.pop(bytes)) {
@@ -219,11 +216,12 @@ auto DataProcessor::process_collection(
return result; return result;
} }
auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface& { auto DataProcessor::requested_mode(const ProcessingLiveConfig& live_config) const -> const std::string& {
const std::string requested_mode = return live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
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); return *(found->second);
} }
if (auto fallback = processors_.find(default_processor_mode_); fallback != processors_.end()) { 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 { auto DataProcessor::should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool {
const std::string requested_mode = return requested_mode(live_config) == "bscan";
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; }
return requested_mode == "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<double> { auto DataProcessor::socket_speed(const ProcessingLiveConfig& live_config) const -> std::optional<double> {
@@ -93,24 +93,9 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
return std::abs(sample); return std::abs(sample);
} }
[[nodiscard]] auto fallback_profile( // A range profile needs at least two in-band points on an increasing frequency grid.
const ipc::SweepTraceBlock& trace, // Otherwise the profile is empty and the display leaves the column out: drawing |S21|
std::span<const ipc::Complex32> selected_samples // against a made-up depth axis would look like a valid B-scan.
) -> 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<float>(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<float>(static_cast<float>(index) / denominator));
fallback.response.push_back(std::sqrt(sample.re * sample.re + sample.im * sample.im));
}
return fallback;
}
[[nodiscard]] auto compute_bscan_profile( [[nodiscard]] auto compute_bscan_profile(
const ipc::SweepTraceBlock& trace, const ipc::SweepTraceBlock& trace,
const ProcessingLiveConfig& live_config const ProcessingLiveConfig& live_config
@@ -118,7 +103,7 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
const auto selected_samples = std::span<const ipc::Complex32>(trace.s21); const auto selected_samples = std::span<const ipc::Complex32>(trace.s21);
const std::size_t point_count = std::min(trace.frequency_hz.size(), selected_samples.size()); const std::size_t point_count = std::min(trace.frequency_hz.size(), selected_samples.size());
if (point_count < 2U) { if (point_count < 2U) {
return fallback_profile(trace, selected_samples); return {};
} }
const double configured_start_hz = static_cast<double>(live_config.bscan_start_freq_mhz) * 1'000'000.0; const double configured_start_hz = static_cast<double>(live_config.bscan_start_freq_mhz) * 1'000'000.0;
@@ -142,35 +127,35 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
} }
if (filtered_freq_hz.size() < 2U) { if (filtered_freq_hz.size() < 2U) {
return fallback_profile(trace, selected_samples); return {};
} }
const std::size_t filtered_count = filtered_freq_hz.size(); const std::size_t filtered_count = filtered_freq_hz.size();
const double df = (filtered_freq_hz.back() - filtered_freq_hz.front()) / static_cast<double>(filtered_count - 1U); const double df = (filtered_freq_hz.back() - filtered_freq_hz.front()) / static_cast<double>(filtered_count - 1U);
if (df <= 0.0) { if (df <= 0.0) {
return fallback_profile(trace, selected_samples); return {};
} }
const auto start_bin = static_cast<std::int64_t>(std::llround(filtered_freq_hz.front() / df)); const auto start_bin = static_cast<std::int64_t>(std::llround(filtered_freq_hz.front() / df));
if (start_bin < 0) { if (start_bin < 0) {
return fallback_profile(trace, selected_samples); return {};
} }
const auto start_index = static_cast<std::size_t>(start_bin); const auto start_index = static_cast<std::size_t>(start_bin);
if (start_index > (std::numeric_limits<std::size_t>::max() / 2U)) { if (start_index > (std::numeric_limits<std::size_t>::max() / 2U)) {
return fallback_profile(trace, selected_samples); return {};
} }
if (start_index > (std::numeric_limits<std::size_t>::max() - filtered_count + 1U)) { if (start_index > (std::numeric_limits<std::size_t>::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 min_fft_len = 2U * (start_index + filtered_count - 1U);
const std::size_t fft_len = next_power_of_two(min_fft_len); 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) { 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)) { if (start_index > fft_len || filtered_count > (fft_len - start_index)) {
return fallback_profile(trace, selected_samples); return {};
} }
std::vector<std::complex<double>> spectrum(fft_len, std::complex<double>(0.0, 0.0)); std::vector<std::complex<double>> spectrum(fft_len, std::complex<double>(0.0, 0.0));
+5
View File
@@ -237,6 +237,11 @@ ref» — сигнал на первом приёмнике.
слейвов на барьере синхронизации. Слив очередей, ожидание начала цикла и слейвов на барьере синхронизации. Слив очередей, ожидание начала цикла и
повторные попытки сбора в окно не входят. повторные попытки сбора в окно не входят.
Внутри свипа индекс точки только растёт, поэтому цикл прибора закрывается на
первой точке с неувеличившимся индексом — начале следующего свипа, даже если его
точка 0 потеряна. Точки следующего свипа в цикл не попадают. Потерянные точки
цикла становятся нулём; если таких больше 5 % трассы, цикл собирается заново.
### Формат коллекций между процессами хоста ### Формат коллекций между процессами хоста
Это отдельный формат от USB-пакетов прибора. `RAW3` и `PRP3` — четыре ASCII-байта Это отдельный формат от USB-пакетов прибора. `RAW3` и `PRP3` — четыре ASCII-байта
+3 -3
View File
@@ -116,14 +116,14 @@ class BscanReplay:
finally: finally:
self.active = False self.active = False
# Tap and results rings drop independently: only these replayed keys belong # Tap and results rings drop independently: only these replayed keys belong in the
# in the refreshed image; unrelated runtime results may still use old settings. # 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] self.history.replay_results[:] = [replayed[key] for key in keys if key in replayed]
received = sum(key in replayed for key in keys[:sent]) received = sum(key in replayed for key in keys[:sent])
if received < sent: if received < sent:
self._warning( self._warning(
f"B-scan replay recovered {received} of {sent} re-sent sweeps; " 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}", once_key=f"bscan_replay_incomplete_{sent}_{received}",
) )
self._debug(f"B-scan replay finished: sent={sent}, recovered={received}.") self._debug(f"B-scan replay finished: sent={sent}, recovered={received}.")
@@ -120,27 +120,15 @@ class LiveSettingsController:
def _redraw(self) -> None: def _redraw(self) -> None:
try: try:
mode = self._mode.currentText() mode, pipeline = self._mode.currentText(), self._pipeline
pipeline, plots, history = self._pipeline, self._pipeline.plots, self._pipeline.history if mode == "bscan" and pipeline.can_reprocess_history():
if mode == "bscan": pipeline.schedule_replay()
if pipeline.can_reprocess_history(): elif mode == "bscan":
pipeline.schedule_replay() pipeline.drain_results(timeout_s=0.25, poll_s=0.01)
else:
pipeline.drain_results(timeout_s=0.25, poll_s=0.01)
plots.draw_bscan()
elif mode in _GEOMETRY_MODES: elif mode in _GEOMETRY_MODES:
latest = pipeline.drain_results(timeout_s=0.8, poll_s=0.02) # Only the published revision is drawn: wait for the newest sweep recomputed with it.
collection = latest if latest is not None else history.results[-1] if history.results else None pipeline.drain_results(timeout_s=0.8, poll_s=0.02, revision=pipeline.plots.required_revision)
if mode == "sar3d": pipeline.plots.redraw()
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()
except Exception as exc: except Exception as exc:
self._log.show_exception("Failed to render processing settings", exc) self._log.show_exception("Failed to render processing settings", exc)
@@ -171,7 +159,7 @@ class LiveSettingsController:
def locator_changed(self, *_args: object) -> None: def locator_changed(self, *_args: object) -> None:
if self._mode.currentText() == "gpr" and self._publish_changes(): if self._mode.currentText() == "gpr" and self._publish_changes():
self.visual_changed() self._redraw()
def set_mode_page(self, mode: str) -> None: def set_mode_page(self, mode: str) -> None:
pages = self._processing.pages pages = self._processing.pages
@@ -80,6 +80,9 @@ class PipelineController:
self._raw_reader: ShmRingReader | None = None self._raw_reader: ShmRingReader | None = None
self._pre_reader: ShmRingReader | None = None self._pre_reader: ShmRingReader | None = None
self._result_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: RunConfigModel | None = None
self.active_config_path: Path | None = None self.active_config_path: Path | None = None
self.stop_generation = 0 self.stop_generation = 0
@@ -203,6 +206,7 @@ class PipelineController:
raise error raise error
if not operation.valid: if not operation.valid:
raise InterruptedError("Pipeline operation cancelled") raise InterruptedError("Pipeline operation cancelled")
self._display_pending = False # the pausing operation owns the display
return operation return operation
except BaseException: except BaseException:
operation.finish(resume=False) operation.finish(resume=False)
@@ -478,11 +482,14 @@ class PipelineController:
finally: finally:
if self.single_capture_active: if self.single_capture_active:
self._check_single_capture_deadline() self._check_single_capture_deadline()
elif latest[2] is not None: elif self._display_pending:
self._restart_count = 0 if latest[2] is not None:
started = time.monotonic_ns() self._restart_count = 0 # results flow again
self.plots.draw(latest[2]) self._display_pending = False
self.metrics.record("rendering", time.monotonic_ns() - started) 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: except Exception as exc:
self._report_ui_error("Result display", exc) self._report_ui_error("Result display", exc)
@@ -634,6 +641,7 @@ class PipelineController:
self.metrics.record("processing", collection.processing_duration_ns) self.metrics.record("processing", collection.processing_duration_ns)
if self.history.record_result(collection): if self.history.record_result(collection):
self.recording.record("results", collection) self.recording.record("results", collection)
self._display_pending = True
latest = collection latest = collection
return latest return latest
@@ -662,17 +670,20 @@ class PipelineController:
stable = 0 if self.drain_once() else stable + 1 stable = 0 if self.drain_once() else stable + 1
self.pump_events(poll_s) self.pump_events(poll_s)
def drain_results(self, *, timeout_s: float, poll_s: float) -> ResultCollection | None: def drain_results(self, *, timeout_s: float, poll_s: float, revision: int | None = None) -> None:
deadline, stable = time.monotonic() + timeout_s, 0 """Read results until the timeout; stop two quiet polls after results arrive or,
latest_seen = None given a revision, as soon as the newest result carries it."""
while self._result_reader is not None and time.monotonic() < deadline and (latest_seen is None or stable < 2): deadline, seen, stable = time.monotonic() + timeout_s, False, 0
latest = self.read_results() while self._result_reader is not None and time.monotonic() < deadline:
if latest is not None: if self.read_results() is not None:
latest_seen, stable = latest, 0 seen, stable = True, 0
elif latest_seen is not None: elif seen:
stable += 1 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) self.pump_events(poll_s)
return latest_seen
def drain_snapshot(self) -> None: def drain_snapshot(self) -> None:
initial_readers = self.readers initial_readers = self.readers
+45 -26
View File
@@ -2,6 +2,7 @@
from collections.abc import Callable from collections.abc import Callable
import pyqtgraph as pg
from PyQt6.QtCore import QSignalBlocker from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QComboBox, QPushButton, QStackedWidget, QWidget 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.gui_profile_schema import GuiProcessingStateModel
from python_app.models.run_config_schema import GprModel from python_app.models.run_config_schema import GprModel
Renderer = BscanRenderer | GprRenderer | Sar3dRenderer | TraceRenderer
class PlotController: class PlotController:
"""Own the plot views: renderers draw and clear only through it, and it counts image changes."""
def __init__( def __init__(
self, history: HistoryController, settings: Callable[[], GuiProcessingStateModel], self, history: HistoryController, settings: Callable[[], GuiProcessingStateModel],
geometry: Callable[[], GprModel], running: Callable[[], bool], geometry: Callable[[], GprModel], running: Callable[[], bool],
@@ -36,9 +41,28 @@ class PlotController:
for renderer in (self.bscan, self.gpr, self.sar3d, self.trace): for renderer in (self.bscan, self.gpr, self.sar3d, self.trace):
self.stack.addWidget(renderer.widget) self.stack.addWidget(renderer.widget)
self.stack.setCurrentWidget(self.trace.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._combo: QComboBox | None = None
self._next: QPushButton | 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: def bind_display_controls(self, combo: QComboBox, next_button: QPushButton) -> None:
self._combo, self._next = combo, next_button self._combo, self._next = combo, next_button
combo.currentIndexChanged.connect(self.on_display_combo_changed) 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 return self.sar3d.grid if current is self.sar3d.widget else current
def set_mode(self, mode: str) -> None: def set_mode(self, mode: str) -> None:
renderer = {"bscan": self.bscan, "gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace) self.stack.setCurrentWidget(self._renderer(mode).widget)
self.stack.setCurrentWidget(renderer.widget)
self.sync_display_combos() self.sync_display_combos()
def select_combo(self, input_pos: int, output_pos: int) -> None: def select_combo(self, input_pos: int, output_pos: int) -> None:
@@ -96,6 +119,7 @@ class PlotController:
self.sync_display_combos() self.sync_display_combos()
def draw_bscan(self) -> bool: def draw_bscan(self) -> bool:
self._changed()
self.sync_bscan() self.sync_bscan()
window, retained = self.window_scans(), len(self.history.preprocessed) window, retained = self.window_scans(), len(self.history.preprocessed)
if retained < window and self.bscan.history_by_combo: if retained < window and self.bscan.history_by_combo:
@@ -119,6 +143,7 @@ class PlotController:
return False return False
if mode == "bscan": if mode == "bscan":
return self.draw_bscan() return self.draw_bscan()
self._changed()
if mode == "gpr": if mode == "gpr":
return self.gpr.draw(collection, settings.gpr, self._geometry()) return self.gpr.draw(collection, settings.gpr, self._geometry())
if mode == "sar3d": if mode == "sar3d":
@@ -126,42 +151,36 @@ class PlotController:
return self.trace.draw(collection, settings.pass_through) return self.trace.draw(collection, settings.pass_through)
def redraw(self, *_args: object) -> None: def redraw(self, *_args: object) -> None:
"""Show the newest result with the current settings."""
mode = self._settings().selected_mode mode = self._settings().selected_mode
if mode == "bscan": if mode == "bscan":
self.draw_bscan() self.draw_bscan()
elif mode == "sar3d": return
self.sar3d.clear() # Cleared first: a result of another settings revision is not drawn and must not
if self.history.results: # leave the previous image, while a renderer's own failure message is kept.
self.draw(self.history.results[-1]) self._changed()
elif not self.history.results or not self.draw(self.history.results[-1]): self._renderer(mode).clear()
self.clear_current() if self.history.results:
self.draw(self.history.results[-1])
def draw_trace(self, trace: TraceData, title: str) -> None: def draw_trace(self, trace: TraceData, title: str) -> None:
self._changed()
self.trace.draw_trace(trace, title, self._settings().pass_through) 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: def clear_bscan(self) -> None:
self._changed()
self.bscan.clear() self.bscan.clear()
self.sync_display_combos() 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: def clear(self) -> None:
self.clear_bscan() self._changed()
self.clear_gpr() for renderer in (self.bscan, self.gpr, self.sar3d, self.trace):
self.sar3d.clear() renderer.clear()
self.trace.clear() self.sync_display_combos()
@staticmethod @staticmethod
def has_output(collection: ResultCollection) -> bool: def has_output(collection: ResultCollection) -> bool:
@@ -324,7 +324,7 @@ class PreprocessController:
dialog.clear_capture_log() dialog.clear_capture_log()
dialog.reset_preview() dialog.reset_preview()
dialog.set_status(f"{preprocess_asset_display_name(kind)} sequence started") dialog.set_status(f"{preprocess_asset_display_name(kind)} sequence started")
self._plots.trace.clear() self._plots.clear_trace()
self._update_capture_state() self._update_capture_state()
logger.info("%s sequence started: set=%s, combos=%d, radar_variants=%d, median_sweeps=%d", 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) kind, name, session.state().total_count, len(keys), settings.median_sweep_count)
@@ -417,7 +417,7 @@ class PreprocessController:
trace = session.last_captured_trace() trace = session.last_captured_trace()
if trace is None: if trace is None:
dialog.reset_preview() dialog.reset_preview()
self._plots.trace.clear() self._plots.clear_trace()
else: else:
dialog.draw_last_trace(trace, title=f"{display} last trace") dialog.draw_last_trace(trace, title=f"{display} last trace")
self._plots.draw_trace(trace, f"{display} last trace") self._plots.draw_trace(trace, f"{display} last trace")
+14 -9
View File
@@ -4,14 +4,13 @@ from __future__ import annotations
from collections import deque from collections import deque
from PyQt6.QtCore import QRectF
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from python_app.models.collection_identity import collection_key from python_app.models.collection_identity import collection_key
from python_app.models.dataset_model import ResultCollection from python_app.models.dataset_model import ResultCollection
from python_app.models.gui_profile_schema import GuiBscanStateModel 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( def _result_tail(
@@ -109,16 +108,18 @@ def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
return -max_abs, max_abs return -max_abs, max_abs
_TITLE = "B-scan S21"
class BscanRenderer: class BscanRenderer:
"""Own the heatmap and its per-combination arrays; input history stays external.""" """Own the heatmap and its per-combination arrays; input history stays external."""
def __init__(self) -> None: def __init__(self) -> None:
self.widget = create_plot() self.widget = create_plot(_TITLE)
self.widget.setLabel("bottom", "Sweep #") self.widget.setLabel("bottom", "Sweep #")
self.widget.setLabel("left", "Range", units="m") self.widget.setLabel("left", "Range", units="m")
item = self.widget.getPlotItem() item = self.widget.getPlotItem()
assert item is not None assert item is not None
item.getViewBox().invertY(False)
item.getViewBox().enableAutoRange(x=False, y=False) item.getViewBox().enableAutoRange(x=False, y=False)
self.image = pg.ImageItem(axisOrder="row-major") self.image = pg.ImageItem(axisOrder="row-major")
item.addItem(self.image) item.addItem(self.image)
@@ -139,24 +140,28 @@ class BscanRenderer:
history = self.history_by_combo.get(key) if key is not None else None 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 depth = self.depth_axis_by_combo.get(key) if key is not None else None
if not history or depth is None: if not history or depth is None:
self.image.clear() self._show_empty()
return False return False
sweeps = apply_mean_ascan_subtraction(history, enabled=settings.subtract_mean_ascan) 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] count = sweeps.shape[0]
rect = image_rect(np.arange(1, count + 1), depth)
self.image.setImage(sweeps.T, autoLevels=False) 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.setLookupTable(bscan_lookup_table(settings.axis))
self.image.setLevels(bscan_levels(sweeps, settings.axis)) self.image.setLevels(bscan_levels(sweeps, settings.axis))
item = self.widget.getPlotItem() item = self.widget.getPlotItem()
assert item is not None 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 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 return True
def clear(self) -> None: def clear(self) -> None:
self.history_by_combo.clear() self.history_by_combo.clear()
self.depth_axis_by_combo.clear() self.depth_axis_by_combo.clear()
self.signature = None self.signature = None
self._show_empty()
def _show_empty(self) -> None:
self.image.clear() self.image.clear()
self.widget.setTitle(_TITLE)
+27 -5
View File
@@ -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 numpy as np
import pyqtgraph as pg 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_schema import GprModel
from python_app.models.run_config_validation import parse_position_csv 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: 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) plot.showGrid(x=True, y=True, alpha=0.2)
return plot return plot
@@ -26,8 +36,20 @@ def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
return lut return lut
# Same Tx/Rx markers on ground BP and the three planar SAR views. def image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
ANTENNA_STYLES = (("t", "#ff595e", "#ffca3a"), ("t1", "#4cc9f0", "#e0fbfc")) """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]: def selected_antenna_coordinates(geometry: GprModel, inputs: str, outputs: str) -> tuple[np.ndarray, np.ndarray]:
+33 -61
View File
@@ -6,10 +6,13 @@ from PyQt6.QtCore import QRectF
import numpy as np import numpy as np
import pyqtgraph as pg 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 ( from python_app.gui.result_payloads import (
collection_payload_by_name, gpr_object_rows, 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.dataset_model import ResultCollection, ResultPayload
from python_app.models.gui_profile_schema import GuiGprStateModel from python_app.models.gui_profile_schema import GuiGprStateModel
from python_app.models.run_config_schema import GprModel 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.""" """Draw one collection from explicit display settings and antenna geometry."""
def __init__(self) -> None: def __init__(self) -> None:
self.widget = create_plot("GPR coherent BP — ground plane") self.widget = create_plot(_title())
plot = self.widget.getPlotItem() plot = self.widget.getPlotItem()
assert plot is not None assert plot is not None
self._plot = plot self._plot = plot
self._view = plot.getViewBox() self._view = plot.getViewBox()
self._plot.showGrid(x=True, y=True, alpha=0.2) plot.setLabel("bottom", "X", units="m")
self._configure_axes() 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._lookup_table = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._image = pg.ImageItem(axisOrder="row-major") self._image = pg.ImageItem(axisOrder="row-major")
self._tx = pg.ScatterPlotItem() self._tx = pg.ScatterPlotItem()
@@ -80,21 +86,6 @@ class GprRenderer:
self._geometry_signature: tuple[object, ...] | None = None self._geometry_signature: tuple[object, ...] | None = None
self._selected_geometry = (np.empty((0, 3)), np.empty((0, 3))) 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: def _clear_annotations(self) -> None:
for item in self._labels: for item in self._labels:
self._plot.removeItem(item) self._plot.removeItem(item)
@@ -102,11 +93,9 @@ class GprRenderer:
def clear(self) -> None: def clear(self) -> None:
"""Clear displayed data while retaining reusable graphics items.""" """Clear displayed data while retaining reusable graphics items."""
self._configure_axes()
self._clear_annotations() self._clear_annotations()
self._image.hide() self._image.hide()
for marker in self._markers: for marker in self._markers:
marker.setData(x=[], y=[])
marker.hide() marker.hide()
self._plot.setTitle(_title()) self._plot.setTitle(_title())
@@ -128,8 +117,16 @@ class GprRenderer:
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0)) brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0))
marker.setVisible(len(coordinates) > 0) marker.setVisible(len(coordinates) > 0)
def _label(self, text: str, x: float, y: float, color: str) -> pg.TextItem: def _draw_image(self, accumulator: ResultPayload) -> None:
label = pg.TextItem(text=text, color=color, anchor=(0.0, 1.0)) 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.setZValue(40)
label.setPos(x, y) label.setPos(x, y)
self._plot.addItem(label) self._plot.addItem(label)
@@ -153,6 +150,7 @@ class GprRenderer:
self, rows: np.ndarray, objects_only: bool, x_span: float, z_span: float, self, rows: np.ndarray, objects_only: bool, x_span: float, z_span: float,
) -> None: ) -> None:
if rows.size == 0: if rows.size == 0:
self._points.hide()
return return
self._points.setData( self._points.setData(
x=rows[:, 0], y=rows[:, 1], symbol="o" if objects_only else "d", x=rows[:, 0], y=rows[:, 1], symbol="o" if objects_only else "d",
@@ -167,7 +165,7 @@ class GprRenderer:
text = f"{float(score):.2f}" text = f"{float(score):.2f}"
if objects_only: if objects_only:
text += f" | x={x:.1f} | z={z:.1f}" 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: if objects_only:
self._place_object_label(label, x, z, x_span, z_span, occupied) 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.""" """Render a heatmap or object-only view, returning whether data is present."""
objects_only = settings.render_mode == "objects_only" objects_only = settings.render_mode == "objects_only"
accumulator = collection_payload_by_name(collection, "gpr_accumulator", kind=3) 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) rows = gpr_object_rows(collection)
if objects_only: if objects_only:
if accumulator is None and rows.size == 0: if accumulator is None and rows.size == 0:
@@ -186,42 +182,18 @@ class GprRenderer:
self.clear() self.clear()
return False return False
self.widget.setUpdatesEnabled(False) x_min, x_max = normalized_display_range(settings.visible_x_min_m, settings.visible_x_max_m)
try: z_min, z_max = normalized_display_range(settings.visible_z_min_m, settings.visible_z_max_m)
self._clear_annotations() 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() self._image.hide()
for marker in (self._points,): else:
marker.setData(x=[], y=[]) self._draw_image(accumulator)
marker.hide() self._draw_points(rows, objects_only, x_max - x_min, z_max - z_min)
self._view.invertY(False) self._plot.setTitle(_title(objects_only=objects_only))
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)
return True return True
@staticmethod @staticmethod
+20 -25
View File
@@ -24,8 +24,11 @@ import pyqtgraph as pg
from PyQt6.QtCore import QRectF, Qt from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget 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.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.dataset_model import ResultCollection, ResultPayload
from python_app.models.gui_profile_schema import GuiSar3dStateModel from python_app.models.gui_profile_schema import GuiSar3dStateModel
from python_app.models.run_config_schema import GprModel 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. # Status codes published by the sar3d processor in its "sar3d_status" table.
SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"} 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) # 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_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = {
"sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)), "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") 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: class Sar3dRenderer:
"""Draws the 3D SAR views.""" """Draws the 3D SAR views."""
@@ -85,21 +84,19 @@ class Sar3dRenderer:
grid_layout.setContentsMargins(0, 0, 0, 0) grid_layout.setContentsMargins(0, 0, 0, 0)
grid_layout.setSpacing(6) grid_layout.setSpacing(6)
layout.addWidget(self.grid, stretch=1) 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 = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) # the GPR palette
lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)]) lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)])
lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD
self.plots: dict[str, pg.PlotWidget] = {}
self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {} self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {}
self.images: dict[str, pg.ImageItem] = {} self.images: dict[str, pg.ImageItem] = {}
self.targets: dict[str, pg.PlotCurveItem] = {} self.targets: dict[str, pg.PlotCurveItem] = {}
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items(): for index, (name, (title, x_label, y_label, axes)) in enumerate(SAR3D_VIEWS.items()):
plot = self.plots[name] plot = create_plot(title)
grid_layout.addWidget(plot, index // 2, index % 2)
self.plots[name] = plot
plot.setAspectLocked(True) plot.setAspectLocked(True)
if _axes is not None: if axes is not None:
markers = (pg.ScatterPlotItem(), pg.ScatterPlotItem()) markers = (pg.ScatterPlotItem(), pg.ScatterPlotItem())
for marker in markers: for marker in markers:
marker.setZValue(4) marker.setZValue(4)
@@ -109,42 +106,40 @@ class Sar3dRenderer:
assert item is not None assert item is not None
view = item.getViewBox() view = item.getViewBox()
view.setMouseEnabled(x=False, y=False) view.setMouseEnabled(x=False, y=False)
view.setMenuEnabled(False)
view.enableAutoRange(x=False, y=False) view.enableAutoRange(x=False, y=False)
item.hideButtons() item.hideButtons()
image = pg.ImageItem(axisOrder="row-major", lut=lut) image = pg.ImageItem(axisOrder="row-major", lut=lut)
plot.addItem(image) plot.addItem(image)
# Hollow, so the response at the maximum stays visible. # 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) peak.setZValue(3)
plot.addItem(peak) plot.addItem(peak)
self.images[name] = image self.images[name] = image
self.targets[name] = peak self.targets[name] = peak
if x_label: if x_label:
plot.showGrid(x=True, y=True, alpha=0.2)
plot.setLabel("bottom", x_label, units="m") plot.setLabel("bottom", x_label, units="m")
plot.setLabel("left", y_label, units="m") plot.setLabel("left", y_label, units="m")
if y_label == "Y": 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) ground.setZValue(2)
plot.addItem(ground) plot.addItem(ground)
oblique = self.plots["sar3d_oblique"] oblique = self.plots["sar3d_oblique"]
oblique.hideAxis("bottom") oblique.hideAxis("bottom")
oblique.hideAxis("left") oblique.hideAxis("left")
oblique.setAspectLocked(True)
# The notebook scene's ground, under the image so it shows through weak voxels. # The notebook scene's ground, under the image so it shows through weak voxels.
self.ground = pg.PlotCurveItem( 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) self.ground.setZValue(-1)
oblique.addItem(self.ground) 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) self.box.setZValue(2)
oblique.addItem(self.box) oblique.addItem(self.box)
self.axis_labels: dict[str, pg.TextItem] = {} self.axis_labels: dict[str, pg.TextItem] = {}
for axis_name in _AXIS_EDGES: 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) oblique.addItem(label)
self.axis_labels[axis_name] = label self.axis_labels[axis_name] = label
self.clear() self.clear()
@@ -195,7 +190,7 @@ class Sar3dRenderer:
zoom = settings.zoom_percent / 100.0 zoom = settings.zoom_percent / 100.0
for name, payload in payloads.items(): for name, payload in payloads.items():
title, _x_label, _y_label, axes = SAR3D_VIEWS[name] 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 = self.images[name]
image.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0), image.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0),
autoLevels=False, levels=(0, 1)) autoLevels=False, levels=(0, 1))
+32 -46
View File
@@ -6,10 +6,10 @@ from typing import Protocol
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PyQt6.QtCore import Qt from PyQt6.QtGui import QPen
from PyQt6.QtWidgets import QVBoxLayout, QWidget 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.dataset_model import ComboKey, ResultCollection, TraceData
from python_app.models.gui_profile_schema import GuiPassThroughStateModel from python_app.models.gui_profile_schema import GuiPassThroughStateModel
from python_app.models.run_config_validation import parse_combos_from_text from python_app.models.run_config_validation import parse_combos_from_text
@@ -21,9 +21,7 @@ class WarningLogger(Protocol):
CurveKey = tuple[ComboKey, int, str] CurveKey = tuple[ComboKey, int, str]
Series = tuple[CurveKey, np.ndarray, np.ndarray, str] Series = tuple[CurveKey, np.ndarray, np.ndarray, QPen]
_PALETTE = ("#4cc9f0", "#f72585", "#b8f2e6", "#ffd166",
"#90be6d", "#ff595e", "#6a4c93", "#1982c4")
class _TracePanel: class _TracePanel:
@@ -35,57 +33,49 @@ class _TracePanel:
assert item is not None assert item is not None
self.item = item self.item = item
self.view = item.getViewBox() self.view = item.getViewBox()
self.item.showGrid(x=True, y=True, alpha=0.2)
self.item.setLabel("left", label, units=units) self.item.setLabel("left", label, units=units)
self.item.setLabel("bottom", "Frequency", units="Hz") self.item.setLabel("bottom", "Frequency", units="Hz")
self.item.setDownsampling(mode="peak") # Sweeps denser than the screen are drawn as per-bin min/max, so narrow peaks
self.item.setClipToView(True) # and notches stay visible; ordinary sweeps keep every point.
self.curves: dict[CurveKey, pg.PlotCurveItem] = {} self.item.setDownsampling(auto=True, mode="peak")
self.legend: pg.LegendItem | None = None self.curves: dict[CurveKey, pg.PlotDataItem] = {}
self._legend_sources: dict[ComboKey, pg.PlotCurveItem] = {} 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: def clear(self) -> None:
self.item.clear() self.item.clear()
self.curves.clear() self.curves.clear()
self.sync_legend({}) self.sync_legend({})
def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray, def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray, pen: QPen) -> pg.PlotDataItem:
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)
curve = self.curves.get(key) curve = self.curves.get(key)
if curve is None: if curve is None:
curve = pg.PlotCurveItem(pen=pen, antialias=False) curve = pg.PlotDataItem(antialias=False)
self.curves[key] = curve self.curves[key] = curve
self.item.addItem(curve) self.item.addItem(curve)
else: curve.setData(x, y, pen=pen) # one update; a separate setPen would redraw the old data first
curve.setPen(pen)
curve.setData(x, y)
return curve return curve
def retain_curves(self, active_keys: set[CurveKey]) -> None: def retain_curves(self, active_keys: set[CurveKey]) -> None:
for key in self.curves.keys() - active_keys: for key in self.curves.keys() - active_keys:
self.item.removeItem(self.curves.pop(key)) 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: if self._legend_sources == sources:
return return
if self.legend is None and sources: self.legend.clear()
self.legend = self.item.addLegend(offset=(8, 8)) for combo in sorted(sources, key=lambda key: (key.input, key.output)):
if self.legend is not None: self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}")
self.legend.clear() self.legend.setVisible(bool(sources))
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 self._legend_sources = sources
class TraceRenderer: class TraceRenderer:
"""Render result traces or a captured trace using explicit display settings.""" """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._log_warning = log_warning
self.max_points = max_points
self.widget = QWidget() self.widget = QWidget()
layout = QVBoxLayout(self.widget) layout = QVBoxLayout(self.widget)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@@ -102,23 +92,24 @@ class TraceRenderer:
def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool: def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool:
combo_filter = self._combo_filter(settings.combo_filter) combo_filter = self._combo_filter(settings.combo_filter)
colors: dict[ComboKey, str] = {}
series: list[Series] = [] 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: if combo_filter is not None and block.combo not in combo_filter:
continue 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): 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 continue
series.append(((block.combo, index, payload.processing_name), 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") return self._render(series, settings, "Pass-Through S21")
def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None: def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None:
self.clear() self.clear()
self._render([((trace.combo, 0, "__single_trace__"), trace.frequency_hz, 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: def _combo_filter(self, text: str) -> set[ComboKey] | None:
text = text.strip() text = text.strip()
@@ -142,27 +133,23 @@ class TraceRenderer:
return False return False
mag.item.showAxis("bottom", show=not settings.show_phase) mag.item.showAxis("bottom", show=not settings.show_phase)
active: set[CurveKey] = set() active: set[CurveKey] = set()
mag_sources: dict[ComboKey, pg.PlotCurveItem] = {} mag_sources: dict[ComboKey, pg.PlotDataItem] = {}
phase_sources: dict[ComboKey, pg.PlotCurveItem] = {} phase_sources: dict[ComboKey, pg.PlotDataItem] = {}
x_min, x_max = np.inf, -np.inf 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: if not samples.size or frequency.size != samples.size:
continue continue
active.add(key) active.add(key)
x_min, x_max = min(x_min, float(np.min(frequency))), max(x_max, float(np.max(frequency))) 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: if settings.show_magnitude:
magnitude = 20.0 * np.log10(np.maximum(np.abs(samples[::step]), 1e-12)) magnitude = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
curve = mag.update_curve(key, frequency[::step], magnitude, color, curve = mag.update_curve(key, frequency, magnitude, pen)
width=1.8 if single else 1.4)
mag_sources.setdefault(key[0], curve) mag_sources.setdefault(key[0], curve)
if settings.show_phase: if settings.show_phase:
angles = np.angle(samples) angles = np.angle(samples)
if settings.unwrap_phase: if settings.unwrap_phase:
angles = np.unwrap(angles) angles = np.unwrap(angles)
curve = phase.update_curve(key, frequency[::step], np.degrees(angles[::step]), curve = phase.update_curve(key, frequency, np.degrees(angles), pen)
"#80ed99" if single else color,
width=1.4 if single else 1.2, dashed=single)
phase_sources.setdefault(key[0], curve) phase_sources.setdefault(key[0], curve)
for panel, visible, sources in ((mag, settings.show_magnitude, mag_sources), 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 {}) panel.sync_legend(sources if not single else {})
if visible: if visible:
panel.item.setTitle(title) panel.item.setTitle(title)
panel.view.invertY(False)
if active and np.isfinite(x_min) and np.isfinite(x_max): if active and np.isfinite(x_min) and np.isfinite(x_max):
panel.view.setXRange(x_min, x_max, padding=0.02) panel.view.setXRange(x_min, x_max, padding=0.02)
if settings.show_magnitude: if settings.show_magnitude:
@@ -109,6 +109,8 @@ class SnapshotController:
return return
self.pipeline.history_changed() self.pipeline.history_changed()
self.pipeline.plots.redraw() 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( self._log(
f"{action}: dropped raw={dropped_raw}, " f"{action}: dropped raw={dropped_raw}, "
f"preprocessed={dropped_pre}, results={dropped_results}" f"preprocessed={dropped_pre}, results={dropped_results}"
+9 -24
View File
@@ -12,7 +12,6 @@ import threading
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pyqtgraph as pg
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget
@@ -100,20 +99,13 @@ class WebBridge(QObject):
self._frame: dict | None = None self._frame: dict | None = None
self._pending_lock = threading.Lock() self._pending_lock = threading.Lock()
self._pending_calls: set[_WebActionCall] = set() self._pending_calls: set[_WebActionCall] = set()
self._closed = self._streaming = self._grabbing = False self._closed = self._streaming = False
self._dirty = True self._grabbed_revision: int | None = None # plot revision shown by the last grab; None forces one
self._last_png: str | None = None self._last_png: str | None = None
self._last_grab_s = 0.0 self._last_grab_s = 0.0
self._frame_seq = self._snapshot_errors = 0 self._frame_seq = self._snapshot_errors = 0
self._action_requested.connect(self._run_action) self._action_requested.connect(self._run_action)
self._streaming_changed.connect(self._set_streaming) 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: def start_server(self) -> None:
if self._closed or self._server is not None: if self._closed or self._server is not None:
@@ -295,26 +287,19 @@ class WebBridge(QObject):
if not self._closed: if not self._closed:
self._streaming = enabled self._streaming = enabled
if enabled: if enabled:
self._dirty = True self._grabbed_revision = None
def _mark_dirty(self, *_args: object) -> None:
if not self._closed and not self._grabbing:
self._dirty = True
def _grab_frame_if_due(self) -> dict | 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 return None
now = time.monotonic() now = time.monotonic()
if now - self._last_grab_s < _GRAB_INTERVAL_S: if now - self._last_grab_s < _GRAB_INTERVAL_S:
return None return None
self._last_grab_s, self._dirty, self._grabbing = now, False, True self._last_grab_s = now
try: png = self._grab_png()
png = self._grab_png() # Rendering applies pending resizes and lazy auto-ranges; this image already shows them.
except Exception: self._grabbed_revision = plots.revision
self._dirty = True
raise
finally:
self._grabbing = False
if png is None or png == self._last_png: if png is None or png == self._last_png:
return None return None
self._last_png = png self._last_png = png
-3
View File
@@ -7,7 +7,6 @@ from pathlib import Path
import signal import signal
import sys import sys
import pyqtgraph as pg
from PyQt6.QtCore import QTimer from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
@@ -54,8 +53,6 @@ def main() -> int:
"""Run Qt event loop and show main radar control window.""" """Run Qt event loop and show main radar control window."""
app = QApplication(sys.argv) app = QApplication(sys.argv)
apply_light_theme(app) 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) window = AppWindow(PROJECT_ROOT)
if env_enabled("RADAR_SYSTEM_HEADLESS"): if env_enabled("RADAR_SYSTEM_HEADLESS"):
_install_unix_signal_handlers(app, window) _install_unix_signal_handlers(app, window)
@@ -15,7 +15,6 @@ from PyQt6.QtWidgets import (
) )
from python_app.models.dataset_model import SweepCollection from python_app.models.dataset_model import SweepCollection
from python_app.gui.theme import PLOT_BACKGROUND
class ObjectCalibrationDialog(QDialog): class ObjectCalibrationDialog(QDialog):
@@ -132,7 +131,6 @@ class ObjectCalibrationDialog(QDialog):
self._target_combo = QComboBox(self) self._target_combo = QComboBox(self)
layout.addWidget(self._target_combo) layout.addWidget(self._target_combo)
self._phase_view = pg.GraphicsLayoutWidget(parent=self) self._phase_view = pg.GraphicsLayoutWidget(parent=self)
self._phase_view.setBackground(PLOT_BACKGROUND)
self._phase_view.setMinimumHeight(270) self._phase_view.setMinimumHeight(270)
self._phase_plots = [] self._phase_plots = []
self._phase_images = [] self._phase_images = []
+3 -11
View File
@@ -26,7 +26,7 @@ from PyQt6.QtWidgets import (
import numpy as np import numpy as np
import pyqtgraph as pg 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.gui_profile_schema import GuiPreprocessDialogStateModel
from python_app.models.dataset_model import TraceData from python_app.models.dataset_model import TraceData
from python_app.orchestration.preprocess_assets import ( from python_app.orchestration.preprocess_assets import (
@@ -470,17 +470,9 @@ class PreprocessDialog(QDialog):
assert self._amplitude_plot is not None assert self._amplitude_plot is not None
assert self._phase_plot is not None assert self._phase_plot is not None
self._amplitude_plot.clear() self._amplitude_plot.clear()
self._amplitude_plot.plot( self._amplitude_plot.plot(trace.frequency_hz, magnitude_db, pen=CAPTURED_TRACE_PEN)
trace.frequency_hz,
magnitude_db,
pen=pg.mkPen("#4cc9f0", width=1.8),
)
self._phase_plot.clear() self._phase_plot.clear()
self._phase_plot.plot( self._phase_plot.plot(trace.frequency_hz, phase_deg, pen=CAPTURED_TRACE_PEN)
trace.frequency_hz,
phase_deg,
pen=pg.mkPen("#f48c06", width=1.8),
)
elif self._preview_placeholder is not None: elif self._preview_placeholder is not None:
self._preview_placeholder.setText( self._preview_placeholder.setText(
f"{title}\n" f"{title}\n"
+11 -7
View File
@@ -1,11 +1,14 @@
"""Application-wide Qt palette and stylesheet configuration.""" """Application-wide Qt palette, stylesheet and plot colors."""
from __future__ import annotations from __future__ import annotations
import pyqtgraph as pg
from PyQt6.QtGui import QColor, QPalette from PyQt6.QtGui import QColor, QPalette
from PyQt6.QtWidgets import QApplication, QStyleFactory 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 = """ _LIGHT_STYLESHEET = """
QGroupBox { QGroupBox {
@@ -82,7 +85,8 @@ QLabel#hintLabel {
def apply_light_theme(app: QApplication) -> None: 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")) app.setStyle(QStyleFactory.create("Fusion"))
font = app.font() font = app.font()
font.setPointSize(10) font.setPointSize(10)
@@ -90,14 +94,14 @@ def apply_light_theme(app: QApplication) -> None:
palette = QPalette() palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor("#f3f6fb")) 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.Base, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#eef3f8")) palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#eef3f8"))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff")) palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#1f2937")) palette.setColor(QPalette.ColorRole.ToolTipText, QColor(TEXT_COLOR))
palette.setColor(QPalette.ColorRole.Text, QColor("#1f2937")) palette.setColor(QPalette.ColorRole.Text, QColor(TEXT_COLOR))
palette.setColor(QPalette.ColorRole.Button, QColor("#f8fafc")) 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.BrightText, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.Link, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.Link, QColor("#2f7ee6"))
palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6")) palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6"))
+3 -9
View File
@@ -8,7 +8,6 @@ preview into off-screen PNG files, letting us persist a graph for every config.
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -16,13 +15,9 @@ import pyqtgraph as pg
from PyQt6.QtCore import QRectF from PyQt6.QtCore import QRectF
from pyqtgraph.exporters import ImageExporter 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.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) _EXPORT_SIZE = (1400, 520)
@@ -43,7 +38,6 @@ def export_trace_png(
phase_deg = np.degrees(np.angle(samples)) phase_deg = np.degrees(np.angle(samples))
layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE) layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE)
layout.setBackground(PLOT_BACKGROUND)
try: try:
layout.ci.addLabel(title, row=0, col=0, colspan=2) 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.showGrid(x=True, y=True, alpha=0.2)
magnitude_plot.setLabel("bottom", "Frequency", units="Hz") magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
magnitude_plot.setLabel("left", "Magnitude", units="dB") 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 = layout.ci.addPlot(row=1, col=1)
phase_plot.showGrid(x=True, y=True, alpha=0.2) phase_plot.showGrid(x=True, y=True, alpha=0.2)
phase_plot.setLabel("bottom", "Frequency", units="Hz") phase_plot.setLabel("bottom", "Frequency", units="Hz")
phase_plot.setLabel("left", "Phase", units="deg") 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 # An off-screen widget never receives a resizeEvent, so its central
# GraphicsLayout keeps its small preferred size and both plots would be # GraphicsLayout keeps its small preferred size and both plots would be
@@ -68,11 +68,11 @@ class _DeviceCollectionState:
"""Mutable per-device flag marking that this device's capture has finished. """Mutable per-device flag marking that this device's capture has finished.
A device finishes when its final requested sweep cycle closes — either every 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 point of that cycle was delivered, or the point index stopped rising because the
back to point 0, proving the still-missing points are dropped frames that will free-running hardware sweep started over, proving the still-missing points are
never arrive. The collector thread polls this flag so a gap ends the cycle at dropped frames that will never arrive. The collector thread polls this flag so a
the wrap instead of stalling until the no-progress timeout fires; the missing gap ends the cycle there instead of stalling until the no-progress timeout fires;
points are zero-filled when the S-parameters are computed. the missing points are zero-filled when the S-parameters are computed.
""" """
__slots__ = ("last_cycle_closed", "capture_start_ns", "capture_end_ns") __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. """Wrap a cycle-aware handler with cross-device cycle tracking.
Returns a per-datapoint handler that anchors cycle 0 on the first 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 ``point_index == 0`` seen, closes once the point index stops rising (the next
datapoint was consumed. sweep began), and reports whether each datapoint was consumed.
""" """
# In steady state the controller leaves the hardware sweep FREE-RUNNING # In steady state the controller leaves the hardware sweep FREE-RUNNING
# between collections (the unchanged-config fast path only drains the host # 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 # stale point 0 could still have been in flight; a large count means the
# remainder of the in-progress sweep was safely skipped. # remainder of the in-progress sweep was safely skipped.
"pre_anchor_skipped": 0, "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: def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
@@ -504,10 +506,12 @@ def collect_complete_running_sweep_cycle(
cycle_aware_handler(parsed_datapoint) cycle_aware_handler(parsed_datapoint)
return True return True
if current_point_index == 0: if current_point_index <= cycle_tracking_state["last_point_index"]:
# A second point 0 closes this cycle; absent points are now losses. # 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 device_state.last_cycle_closed = True
return False return False
cycle_tracking_state["last_point_index"] = current_point_index
cycle_aware_handler(parsed_datapoint) cycle_aware_handler(parsed_datapoint)
device_state.capture_end_ns = received_ns device_state.capture_end_ns = received_ns
return True return True
+38 -40
View File
@@ -49,52 +49,50 @@ class ShmRingReader:
logger.debug("Closed SHM ring reader %s", self.name) logger.debug("Closed SHM ring reader %s", self.name)
def pop_payload(self) -> bytes | None: def pop_payload(self) -> bytes | None:
"""Read next payload from ring, or `None` if no unread payload exists.""" """Read the next unread payload, or `None` once none remains."""
write_seq = self._read_u64(24) while True:
read_seq = self._read_u64(32) write_seq = self._read_u64(24)
if read_seq >= write_seq: read_seq = self._read_u64(32)
return None 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 index = read_seq % self.capacity
slot_offset = HEADER.size + index * (SLOT_HEADER_SIZE + self.slot_size_bytes) 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 # 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 # 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 # 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. # are read first; the slot is only accepted after the re-read confirms both.
sequence = self._read_u64(slot_offset + 8) if self._read_u64(slot_offset + 8) != read_seq + 1:
if sequence != read_seq + 1: logger.debug("Ring %s: slot lapped before read, resyncing to %d", self.name, resync)
# Producer overwrote this slot before we read it. Resync to latest. atomics.advance(self._mmap, 32, resync)
logger.debug("Ring %s: slot lapped before read, resyncing to write_seq=%d", self.name, write_seq) continue
atomics.advance(self._mmap, 32, write_seq)
return None
payload_size = self._read_u32(slot_offset) payload_size = self._read_u32(slot_offset)
# Bound payload_size against the slot before slicing so a torn/garbage size # 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. # can never read out of the slot region.
if payload_size > self.slot_size_bytes: if payload_size > self.slot_size_bytes:
logger.debug( logger.debug("Ring %s: payload_size %d exceeds slot %d, resyncing to %d",
"Ring %s: payload_size %d exceeds slot %d, resyncing", self.name, payload_size, self.slot_size_bytes, resync)
self.name, atomics.advance(self._mmap, 32, resync)
payload_size, continue
self.slot_size_bytes,
)
atomics.advance(self._mmap, 32, write_seq)
return None
payload_offset = slot_offset + SLOT_HEADER_SIZE payload_offset = slot_offset + SLOT_HEADER_SIZE
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size]) payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
atomics.fence() atomics.fence()
# Re-read the slot sequence after the copy; if it changed, the producer # 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. # overwrote this slot mid-copy and the payload is torn.
if self._read_u64(slot_offset + 8) != read_seq + 1: if self._read_u64(slot_offset + 8) != read_seq + 1:
logger.debug("Ring %s: slot overwritten mid-copy, discarding torn payload", self.name) logger.debug("Ring %s: slot overwritten mid-copy, resyncing to %d", self.name, resync)
atomics.advance(self._mmap, 32, write_seq) atomics.advance(self._mmap, 32, resync)
return None continue
atomics.advance(self._mmap, 32, read_seq + 1) atomics.advance(self._mmap, 32, read_seq + 1)
return payload return payload
def pop_raw_collection(self) -> SweepCollection | None: def pop_raw_collection(self) -> SweepCollection | None:
"""Read next raw collection from ring.""" """Read next raw collection from ring."""
+19
View File
@@ -140,6 +140,25 @@ class ReplayTest(unittest.TestCase):
self.assertIn(unrelated, self.history.results) self.assertIn(unrelated, self.history.results)
self.writer_factory.assert_called_once_with(self.ring.name, self.ring.capacity, self.ring.slot_size_bytes) 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: def test_refuses_without_reader_or_active_ring_geometry(self) -> None:
self.history.preprocessed.append(_sweep(1)) self.history.preprocessed.append(_sweep(1))
self.assertFalse(self.replay.run(window=1, revision=7, ring=self.ring, reader=None, self.assertFalse(self.replay.run(window=1, revision=7, ring=self.ring, reader=None,
@@ -160,6 +160,36 @@ class CaptureWindowTest(unittest.TestCase):
np.testing.assert_array_equal(trace.s21, np.full(5, 0.5)) 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): class CrossDeviceCycleSynchronizerTest(unittest.TestCase):
"""N1: cycle-0 anchors are accepted only when all devices saw the same wrap.""" """N1: cycle-0 anchors are accepted only when all devices saw the same wrap."""
@@ -209,6 +209,44 @@ class FedBackSweepsTest(_ProcessorTestCase):
self._live_writer.write(self._live) self._live_writer.write(self._live)
self.assertEqual(self._published_ids(_SWEEPS), expected) 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: def test_live_geometry_recomputes_retained_gpr_without_restarting(self) -> None:
self._live.processor_mode = "gpr" self._live.processor_mode = "gpr"
self._live.gpr_input_positions = [0] self._live.gpr_input_positions = [0]
+79
View File
@@ -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()
@@ -129,8 +129,9 @@ class ReviewGuiRegressions(unittest.TestCase):
pipeline, supervisor = pipeline_fixture(self) pipeline, supervisor = pipeline_fixture(self)
pipeline.should_run = True pipeline.should_run = True
supervisor.pids.return_value = dict.fromkeys(("acquisition", "data_preprocessor", "data_processor"), 1) supervisor.pids.return_value = dict.fromkeys(("acquisition", "data_preprocessor", "data_processor"), 1)
with mock.patch.object(pipeline, "read_results", return_value=ResultCollection(1, 2)), \ results = iter([ResultCollection(1, 2), None, ResultCollection(2, 3)])
mock.patch.object(pipeline.plots, "draw", side_effect=ValueError("bad display")), \ 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, \ mock.patch.object(pipeline, "stop_all") as stop, \
self.assertLogs("python_app.gui.controllers.pipeline_controller", "ERROR") as logs: self.assertLogs("python_app.gui.controllers.pipeline_controller", "ERROR") as logs:
pipeline.poll() pipeline.poll()
+3 -1
View File
@@ -234,7 +234,9 @@ class Sar3dIntegrationTest(unittest.TestCase):
(_collection(_status(2, "fewer than two frequency points")), "error"), (_collection(_status(2, "fewer than two frequency points")), "error"),
(_collection(_status(1, "No signal in selected depth range")), "no signal"), (_collection(_status(1, "No signal in selected depth range")), "no signal"),
(_projections(), "ready — CUDA")): (_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.view._live.changed()
self.assertIn(expected, self.renderer.status_label.text()) self.assertIn(expected, self.renderer.status_label.text())
self.view._log.show_exception.assert_not_called() self.view._log.show_exception.assert_not_called()
+13 -2
View File
@@ -34,6 +34,7 @@ from python_app.orchestration.shm.decoder import (
decode_result_collection, decode_result_collection,
decode_trace_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_reader import ShmRingReader
from python_app.orchestration.shm.ring_writer import ShmRingWriter from python_app.orchestration.shm.ring_writer import ShmRingWriter
from python_app.storage.npz.serialize import serialize_trace_collection from python_app.storage.npz.serialize import serialize_trace_collection
@@ -304,8 +305,18 @@ class RingOverflowTest(_RingTestCase):
writer.push(b"new") writer.push(b"new")
with mock.patch.object(atomics, "fence", side_effect=overwrite_after_copy): with mock.patch.object(atomics, "fence", side_effect=overwrite_after_copy):
self.assertIsNone(reader.pop_payload()) self.assertEqual(reader.pop_payload(), b"new") # the torn copy is never returned
self.assertEqual(reader.pop_payload(), b"new") 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: def test_reader_cannot_rewind_cursor_after_producer_laps_during_commit(self) -> None:
from python_app.orchestration.shm import atomics from python_app.orchestration.shm import atomics
+146 -34
View File
@@ -1,16 +1,54 @@
"""Plot capture follows client demand and plot changes; redraws leave no stale pixels."""
from contextlib import ExitStack
"""Plot capture follows client demand and actual Qt scene changes."""
from collections import deque from collections import deque
from contextlib import ExitStack
import unittest import unittest
from unittest import mock from unittest import mock
import numpy as np import numpy as np
from PyQt6.QtCore import QSignalBlocker import pyqtgraph as pg
from PyQt6.QtWidgets import QApplication, QComboBox 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): class WebPlotCaptureTest(unittest.TestCase):
@@ -37,49 +75,53 @@ class WebPlotCaptureTest(unittest.TestCase):
self.web._last_grab_s = 0 self.web._last_grab_s = 0
self.web._grab_frame_if_due() self.web._grab_frame_if_due()
def test_no_clients_never_grabs_and_first_client_gets_current_plot(self) -> None: def show_mode(self, name: str) -> None:
self.w._plots.bscan.widget.plot([0, 1], [1, 2])
self.settle()
self.grab.assert_not_called()
controller = self.web
mode = self.w._view.processing.mode mode = self.w._view.processing.mode
with QSignalBlocker(mode): with QSignalBlocker(mode):
mode.setCurrentText("bscan") mode.setCurrentText(name)
self.w._live.active_mode = "bscan" 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.bscan.history_by_combo = {(2, 3): deque([np.array([1., 2.])])}
self.w._plots.sync_display_combos() self.w._plots.sync_display_combos()
self.web.refresh_snapshot() 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)]}) {"enabled": True, "input": 2, "output": 3, "options": [(2, 3)]})
self.grab.assert_not_called() self.grab.assert_not_called()
controller.set_frame_streaming(True) self.web.set_frame_streaming(True)
self.settle() self.settle()
self.assertGreater(self.grab.call_count, 0) self.assertGreater(self.grab.call_count, 0)
self.assertIsNotNone(self.web._last_png) self.assertIsNotNone(self.web._last_png)
calls = self.grab.call_count calls = self.grab.call_count
self.settle() self.settle()
self.assertEqual(self.grab.call_count, calls) # grab does not keep itself dirty self.assertEqual(self.grab.call_count, calls) # a grab does not trigger another one
controller.set_frame_streaming(False) self.web.set_frame_streaming(False)
self.w._plots.bscan.widget.clear() self.w._plots.clear()
self.settle() self.settle()
self.assertEqual(self.grab.call_count, calls) 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) 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() self.settle()
view = plots.trace.magnitude.view
actions = [ actions = [
lambda: plot.plot([0, 1, 2], [1, 4, 2]), lambda: plots.draw(_phase_result(np.full(50, 30.0))),
lambda: view.setXRange(0.25, 1.5, padding=0), lambda: view.setXRange(1.2e9, 1.5e9, padding=0),
plot.clear, plots.redraw,
plots.clear,
lambda: self.w.resize(self.w.width() + 50, self.w.height() + 50), 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: for action in actions:
with self.subTest(action=action): with self.subTest(action=action):
calls = self.grab.call_count calls = self.grab.call_count
@@ -90,18 +132,88 @@ class WebPlotCaptureTest(unittest.TestCase):
self.settle() self.settle()
self.assertEqual(self.grab.call_count, calls) 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.web.set_frame_streaming(True)
self.settle() self.settle()
self.web._mark_dirty() self.grab.reset_mock()
plots.clear()
self.web._last_grab_s = 100 self.web._last_grab_s = 100
with mock.patch("python_app.gui.controllers.web_controller.time.monotonic", return_value=100.1): with mock.patch("python_app.gui.controllers.web_controller.time.monotonic", return_value=100.1):
self.assertIsNone(self.web._grab_frame_if_due()) self.assertIsNone(self.web._grab_frame_if_due())
self.assertTrue(self.web._dirty) self.grab.assert_not_called()
self.grab.side_effect = lambda: (self.web._mark_dirty(), "changed-png")[1] 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): with mock.patch("python_app.gui.controllers.web_controller.time.monotonic", return_value=100.3):
self.assertIsNotNone(self.web._grab_frame_if_due()) 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__": if __name__ == "__main__":
+1 -1
View File
@@ -122,7 +122,7 @@ body {
min-height: 0; min-height: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; 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; overflow: hidden;
} }
#plot { display: block; width: 100%; height: 100%; object-fit: contain; } #plot { display: block; width: 100%; height: 100%; object-fit: contain; }