Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+96 -116
View File
@@ -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__":