fix graph and white background added

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