Rework radar system
This commit is contained in:
@@ -1,15 +1,4 @@
|
||||
"""Configurable B-scan display window.
|
||||
|
||||
The B-scan used to be pinned to the C++ replay window (~50 sweeps) by two separate
|
||||
mechanisms: the render-side history limit and a monotonically rising
|
||||
`collection_id` floor. Widening only the first would have changed nothing, because
|
||||
the floor kept filtering older collections out for good.
|
||||
|
||||
The floor is gone: selection is positional, which is the only criterion that holds
|
||||
when ids are sparse (the results ring drops) or restart from 1 (a new C++ run).
|
||||
These tests pin the observable consequences — how many columns end up on screen —
|
||||
rather than any internal counter.
|
||||
"""
|
||||
"""Stopped B-scan windows and exact result identities through real controllers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,11 +8,13 @@ import unittest
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QComboBox, QLineEdit, QSpinBox # noqa: E402
|
||||
|
||||
from python_app.gui.app_window import AppWindow # noqa: E402
|
||||
from python_app.tests.gui_test_support import isolated_window
|
||||
from python_app.tests.gui_test_support import isolated_window, widget
|
||||
from collections import deque # noqa: E402
|
||||
from unittest import mock
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
|
||||
from python_app.gui.runtime.history import record_result_history # noqa: E402
|
||||
from python_app.models.dataset_model import ( # noqa: E402
|
||||
@@ -31,6 +22,7 @@ from python_app.models.dataset_model import ( # noqa: E402
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
SweepCollection,
|
||||
)
|
||||
|
||||
_app: QApplication | None = None
|
||||
@@ -39,15 +31,11 @@ _window: AppWindow | None = None
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app, _window
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
instance = QApplication.instance()
|
||||
_app = instance if isinstance(instance, QApplication) else QApplication([])
|
||||
_window = isolated_window(unittest.addModuleCleanup)
|
||||
|
||||
|
||||
def tearDownModule() -> None:
|
||||
if _window is not None:
|
||||
_window.close()
|
||||
|
||||
|
||||
def _bscan_result(collection_id: int) -> ResultCollection:
|
||||
payload = ResultPayload(
|
||||
processing_name="bscan",
|
||||
@@ -56,117 +44,131 @@ def _bscan_result(collection_id: int) -> ResultCollection:
|
||||
trace=np.array([collection_id + 0j, collection_id + 0j], dtype=np.complex64),
|
||||
)
|
||||
block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])
|
||||
return ResultCollection(collection_id=collection_id, monotonic_ns=collection_id, blocks=[block])
|
||||
return ResultCollection(collection_id=collection_id, monotonic_ns=collection_id,
|
||||
capture_start_ns=collection_id, capture_end_ns=collection_id + 1, blocks=[block])
|
||||
|
||||
|
||||
class BscanDisplayWindowTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
assert _window is not None
|
||||
self.w = _window
|
||||
self._original_is_running = self.w._supervisor.is_running
|
||||
self.w._result_history.clear()
|
||||
self.w._bscan_render_signature = None
|
||||
self.mode = self.w._view.processing.mode
|
||||
self.history_window = widget(self.w._view.processing, "_bscan_history_window", QSpinBox)
|
||||
with QSignalBlocker(self.mode):
|
||||
self.mode.setCurrentText("bscan")
|
||||
self.w._plots.set_mode("bscan")
|
||||
self._original_is_running = self.w._pipeline.supervisor.is_running
|
||||
self.w._history.results.clear()
|
||||
self.w._plots.bscan.signature = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.w._supervisor.is_running = self._original_is_running
|
||||
self.w._result_history.clear()
|
||||
self.w._bscan_render_signature = None
|
||||
self.w._pipeline.supervisor.is_running = self._original_is_running
|
||||
self.w._history.results.clear()
|
||||
self.w._plots.bscan.signature = None
|
||||
|
||||
def _set_running(self, running: bool) -> None:
|
||||
self.w._supervisor.is_running = lambda: running
|
||||
self.w._pipeline.supervisor.is_running = lambda: running
|
||||
|
||||
def _fill_history(self, count: int, *, id_step: int = 1) -> None:
|
||||
for index in range(count):
|
||||
self.w._result_history.append(_bscan_result(1 + index * id_step))
|
||||
self.w._history.results.append(_bscan_result(1 + index * id_step))
|
||||
|
||||
def test_running_acquisition_ignores_the_user_window(self) -> None:
|
||||
# A live rebuild runs on every incoming result, so the live path stays pinned
|
||||
# to the replay window no matter what the operator typed for stopped review.
|
||||
self._set_running(True)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.assertEqual(self.w._bscan_display_window_scans(), self.w._bscan_cpp_replay_window)
|
||||
self.history_window.setValue(300)
|
||||
self.assertEqual(self.w._plots.window_scans(), self.w._plots.live_window)
|
||||
|
||||
def test_stopped_acquisition_uses_the_user_window(self) -> None:
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.assertEqual(self.w._bscan_display_window_scans(), 300)
|
||||
self.history_window.setValue(300)
|
||||
self.assertEqual(self.w._plots.window_scans(), 300)
|
||||
|
||||
def test_widening_the_window_brings_older_frames_back(self) -> None:
|
||||
# The regression this whole change exists for: a live run renders 50 columns,
|
||||
# and widening the window after Stop must reach back over the retained history
|
||||
# rather than stay pinned to whatever the live path last drew.
|
||||
self._fill_history(300)
|
||||
self._set_running(True)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(
|
||||
len(self.w._bscan_history_by_combo[(0, 0)]), self.w._bscan_cpp_replay_window
|
||||
len(self.w._plots.bscan.history_by_combo[(0, 0)]), self.w._plots.live_window
|
||||
)
|
||||
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
|
||||
self.history_window.setValue(300)
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), 300)
|
||||
|
||||
def test_restarted_collection_ids_do_not_hide_the_fresh_run(self) -> None:
|
||||
# Regression: Start after a stopped review made the image melt to 0 columns and
|
||||
# then snap back to 50. A new C++ run numbers from 1, so entries that are newest
|
||||
# by position carry the smallest ids; any id-based cut-off derived from the old
|
||||
# run rejected exactly them.
|
||||
self._fill_history(300)
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.history_window.setValue(300)
|
||||
self.w._plots.sync_bscan()
|
||||
|
||||
self._set_running(True)
|
||||
window = self.w._bscan_cpp_replay_window
|
||||
window = self.w._plots.live_window
|
||||
for fresh in range(1, window + 1):
|
||||
self.w._result_history.append(_bscan_result(fresh))
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
|
||||
self.w._history.results.append(_bscan_result(fresh))
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), window)
|
||||
|
||||
def test_window_counts_entries_not_collection_ids(self) -> None:
|
||||
# The results ring overwrites unread slots when the producer outruns the GUI
|
||||
# poll loop, so retained collection ids are sparse. Counting in ids rather than
|
||||
# in entries is exactly the case where asking for 150 sweeps rendered only 87.
|
||||
self._fill_history(300, id_step=3)
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(150)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 150)
|
||||
self.history_window.setValue(150)
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), 150)
|
||||
|
||||
def test_reprocessed_results_replace_rather_than_duplicate_columns(self) -> None:
|
||||
# The processor's recompute is triggered by feeding sweeps back through it, and
|
||||
# it republishes them under their ORIGINAL ids. Two independent mechanisms keep
|
||||
# that from doubling every column, and neither may be confused with the removed
|
||||
# `collection_id` floor: a threshold cannot tell a duplicate from its original,
|
||||
# since they share the id. Deduplication is by key equality.
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.history_window.setValue(300)
|
||||
self._fill_history(200)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), 200)
|
||||
|
||||
# Intake: a recomputed collection replaces the entry holding the same key.
|
||||
for collection in list(self.w._result_history):
|
||||
record_result_history(self.w._result_history, _bscan_result(collection.collection_id))
|
||||
self.assertEqual(len(self.w._result_history), 200)
|
||||
for collection in list(self.w._history.results):
|
||||
record_result_history(self.w._history.results, _bscan_result(collection.collection_id))
|
||||
self.assertEqual(len(self.w._history.results), 200)
|
||||
|
||||
# Render: a duplicate that reached the deque by another path is still collapsed,
|
||||
# keeping the newer of the two.
|
||||
self.w._result_history.append(_bscan_result(200))
|
||||
self.w._bscan_render_signature = None
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
|
||||
self.w._history.results.append(_bscan_result(200))
|
||||
self.w._plots.bscan.signature = None
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), 200)
|
||||
|
||||
def test_rebuild_renders_the_full_widened_window(self) -> None:
|
||||
self._fill_history(300)
|
||||
self._set_running(False)
|
||||
self.w._bscan_history_window.setValue(300)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
|
||||
self.history_window.setValue(300)
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), 300)
|
||||
|
||||
self.w._bscan_history_window.setValue(50)
|
||||
self.w._sync_bscan_history_from_results()
|
||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50)
|
||||
self.history_window.setValue(50)
|
||||
self.w._plots.sync_bscan()
|
||||
self.assertEqual(len(self.w._plots.bscan.history_by_combo[(0, 0)]), 50)
|
||||
|
||||
def test_replay_requires_stopped_acquisition_and_live_processor(self) -> None:
|
||||
self.w._history.preprocessed.append(SweepCollection(collection_id=1, monotonic_ns=1, traces=[]))
|
||||
self.addCleanup(self.w._history.preprocessed.clear)
|
||||
with mock.patch.object(self.w._pipeline, "_result_reader", mock.Mock()), \
|
||||
mock.patch.object(self.w._pipeline.supervisor, "is_processor_running", return_value=True):
|
||||
with QSignalBlocker(self.mode):
|
||||
self.mode.setCurrentText("bscan")
|
||||
self._set_running(False)
|
||||
self.assertTrue(self.w._pipeline.can_reprocess_history())
|
||||
self.assertFalse(self.w._live.build(reprocess_current_result=False).reprocess_current_result)
|
||||
self._set_running(True)
|
||||
self.assertFalse(self.w._pipeline.can_reprocess_history())
|
||||
self._set_running(False)
|
||||
with mock.patch.object(self.w._pipeline.supervisor, "is_processor_running", return_value=False):
|
||||
self.assertFalse(self.w._pipeline.can_reprocess_history())
|
||||
with QSignalBlocker(self.mode):
|
||||
self.mode.setCurrentText("pass_through")
|
||||
self.w._plots.set_mode("pass_through")
|
||||
self.assertFalse(self.w._pipeline.can_reprocess_history())
|
||||
self.assertFalse(self.w._pipeline.can_reprocess_history())
|
||||
|
||||
def test_acquisition_combo_editor_does_not_reprocess_display(self) -> None:
|
||||
with mock.patch.object(self.w._live.writer, "write") as write:
|
||||
self.w._view.switches.single_output.setText("1")
|
||||
write.assert_not_called()
|
||||
self.assertFalse(self.w._pipeline.replay.timer.isActive())
|
||||
|
||||
|
||||
class ResultHistoryOrderTest(unittest.TestCase):
|
||||
@@ -174,55 +176,33 @@ class ResultHistoryOrderTest(unittest.TestCase):
|
||||
|
||||
The processor reprocessing its retained sweeps also delivers sweeps whose first
|
||||
result the GUI missed (the results ring overwrites unread slots). Appended, such a
|
||||
sweep showed up as the newest B-scan column, and was recorded to disk once more.
|
||||
sweep showed up as the newest B-scan column. Its first arrival must still be
|
||||
recorded to disk; replacing an already retained result must not duplicate it.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _ids(history: deque[ResultCollection]) -> list[int]:
|
||||
return [int(collection.collection_id) for collection in history]
|
||||
|
||||
def test_a_new_sweep_is_appended(self) -> None:
|
||||
history = deque(_bscan_result(cid) for cid in (1, 2))
|
||||
self.assertTrue(record_result_history(history, _bscan_result(3)))
|
||||
self.assertEqual(self._ids(history), [1, 2, 3])
|
||||
|
||||
def test_a_late_result_takes_its_place_in_acquisition_order(self) -> None:
|
||||
history = deque(_bscan_result(cid) for cid in (1, 2, 4, 5))
|
||||
self.assertFalse(record_result_history(history, _bscan_result(3)))
|
||||
self.assertEqual(self._ids(history), [1, 2, 3, 4, 5])
|
||||
|
||||
def test_a_reprocessed_result_replaces_its_entry(self) -> None:
|
||||
history = deque(_bscan_result(cid) for cid in (1, 2, 3))
|
||||
reprocessed = _bscan_result(2)
|
||||
self.assertFalse(record_result_history(history, reprocessed))
|
||||
self.assertEqual(self._ids(history), [1, 2, 3])
|
||||
self.assertIs(history[1], reprocessed)
|
||||
|
||||
def test_a_full_history_drops_its_oldest_entry(self) -> None:
|
||||
history = deque((_bscan_result(cid) for cid in (1, 2, 4, 5)), maxlen=4)
|
||||
record_result_history(history, _bscan_result(3))
|
||||
self.assertEqual(self._ids(history), [2, 3, 4, 5])
|
||||
record_result_history(history, _bscan_result(1)) # older than all it keeps
|
||||
self.assertEqual(self._ids(history), [2, 3, 4, 5])
|
||||
record_result_history(history, _bscan_result(6))
|
||||
self.assertEqual(self._ids(history), [3, 4, 5, 6])
|
||||
|
||||
def test_only_new_sweeps_are_recorded_to_disk(self) -> None:
|
||||
window = _window
|
||||
assert window is not None
|
||||
arrivals = [_bscan_result(cid) for cid in (2, 3, 2, 1)] # 2 reprocessed, 1 late
|
||||
reader = type("Reader", (), {"pop_result_collection": lambda _self: arrivals.pop(0) if arrivals else None})()
|
||||
reader = mock.Mock(pop_result_collection=mock.Mock(side_effect=lambda: arrivals.pop(0) if arrivals else None))
|
||||
recorded: list[int] = []
|
||||
window._result_reader = reader
|
||||
self.addCleanup(setattr, window, "_result_reader", None)
|
||||
window._record_collection = lambda stage, collection: recorded.append(int(collection.collection_id))
|
||||
self.addCleanup(delattr, window, "_record_collection")
|
||||
window._result_history.clear()
|
||||
self.addCleanup(window._result_history.clear)
|
||||
window._history.results.clear()
|
||||
self.addCleanup(window._history.results.clear)
|
||||
|
||||
window._read_all_results()
|
||||
with mock.patch.object(window._pipeline, "_result_reader", reader), \
|
||||
mock.patch.object(window._recording, "record", side_effect=lambda stage, collection: recorded.append(collection.collection_id)):
|
||||
window._pipeline.read_results()
|
||||
|
||||
self.assertEqual(recorded, [2, 3])
|
||||
self.assertEqual(self._ids(window._result_history), [1, 2, 3])
|
||||
self.assertEqual(recorded, [2, 3, 1])
|
||||
self.assertEqual(self._ids(window._history.results), [1, 2, 3])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user