fix graph and white background added
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ ref» — сигнал на первом приёмнике.
|
|||||||
слейвов на барьере синхронизации. Слив очередей, ожидание начала цикла и
|
слейвов на барьере синхронизации. Слив очередей, ожидание начала цикла и
|
||||||
повторные попытки сбора в окно не входят.
|
повторные попытки сбора в окно не входят.
|
||||||
|
|
||||||
|
Внутри свипа индекс точки только растёт, поэтому цикл прибора закрывается на
|
||||||
|
первой точке с неувеличившимся индексом — начале следующего свипа, даже если его
|
||||||
|
точка 0 потеряна. Точки следующего свипа в цикл не попадают. Потерянные точки
|
||||||
|
цикла становятся нулём; если таких больше 5 % трассы, цикл собирается заново.
|
||||||
|
|
||||||
### Формат коллекций между процессами хоста
|
### Формат коллекций между процессами хоста
|
||||||
|
|
||||||
Это отдельный формат от USB-пакетов прибора. `RAW3` и `PRP3` — четыре ASCII-байта
|
Это отдельный формат от USB-пакетов прибора. `RAW3` и `PRP3` — четыре ASCII-байта
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user