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
+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()
+13 -2
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.assertIsNone(reader.pop_payload())
self.assertEqual(reader.pop_payload(), b"new")
self.assertEqual(reader.pop_payload(), b"new") # the torn copy is never returned
self.assertIsNone(reader.pop_payload())
def test_lapped_reader_continues_with_the_newest_payload(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=4, slot_size=64)
reader = self._reader(name)
for i in range(3):
writer.push(f"f{i}".encode())
writer._write_u64(HEADER.size + 8, 0) # the producer is overwriting the unread f0
self.assertEqual(reader.pop_payload(), b"f2")
self.assertIsNone(reader.pop_payload())
def test_reader_cannot_rewind_cursor_after_producer_laps_during_commit(self) -> None:
from python_app.orchestration.shm import atomics
+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__":