"""Re-feeding retained sweeps to the processor to recompute the whole B-scan. The processor keeps only ~50 preprocessed sweeps of its own, so a live settings edit used to refresh just the newest 50 columns. The GUI holds up to 1000 of them and hands them back through the processor's input ring, which only works if the bytes the GUI writes are exactly the ones the C++ side expects — that wire-format contract is what the first test pins, without needing the pipeline running. The guard tests pin the other half: the GUI must never write into that ring while `data_preprocessor` owns it. """ from __future__ import annotations from contextlib import suppress from dataclasses import replace import json import os from pathlib import Path import unittest import numpy as np os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtCore import QSignalBlocker # noqa: E402 from PyQt6.QtWidgets import QApplication # 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.gui.runtime.history import collection_key # noqa: E402 from python_app.models.dataset_model import ( # noqa: E402 ComboKey, ResultBlock, ResultCollection, ResultPayload, SweepCollection, TraceData, ) from python_app.orchestration.shm import ShmRingWriter # noqa: E402 from python_app.orchestration.shm.ring_reader import ShmRingReader # noqa: E402 from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection # noqa: E402 # Mirrors kPreprocessedCollectionMagic in common_cpp/ipc/src/shared_types.cpp. If this # ever drifts, the processor silently rejects everything the GUI re-feeds. _CPP_PREPROCESSED_MAGIC = 0x32525050 _app: QApplication | None = None _window: AppWindow | None = None def setUpModule() -> None: global _app, _window _app = QApplication.instance() or QApplication([]) _window = isolated_window(unittest.addModuleCleanup) def tearDownModule() -> None: if _window is not None: _window.close() def _sweep(collection_id: int, *, combos: int = 3, points: int = 64) -> SweepCollection: traces = [ TraceData( combo=ComboKey(input=index, output=index + 1), frequency_hz=np.linspace(1e9, 8e9, points, dtype=np.float32), s11=(np.arange(points) + index).astype(np.complex64) + 0.5j, s21=(np.arange(points) * 2 + index).astype(np.complex64) - 0.25j, ) for index in range(combos) ] return SweepCollection( collection_id=collection_id, monotonic_ns=collection_id * 1_000_000 + 7, traces=traces, capture_start_ns=collection_id * 10, capture_end_ns=collection_id * 10 + 5, ) def _bscan_result(collection_id: int, *, points: int = 32) -> ResultCollection: payload = ResultPayload( processing_name="bscan", kind=1, frequency_hz=np.linspace(0.0, 1.0, points, dtype=np.float32), trace=(np.arange(points) + collection_id).astype(np.complex64), ) return ResultCollection( collection_id=collection_id, monotonic_ns=collection_id, blocks=[ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])], ) class PreprocessedWireFormatTest(unittest.TestCase): """The bytes the GUI re-feeds must be the ones the C++ processor decodes.""" def setUp(self) -> None: self.ring_name = f"/radar_test_{self._testMethodName}" with suppress(OSError): (Path("/dev/shm") / self.ring_name[1:]).unlink() self.writer = ShmRingWriter(self.ring_name, 8, 1 << 20) self.reader = ShmRingReader(self.ring_name) self.addCleanup(self._cleanup) def _cleanup(self) -> None: with suppress(Exception): self.reader.close() with suppress(Exception): self.writer.close() with suppress(OSError): (Path("/dev/shm") / self.ring_name[1:]).unlink() def _assert_same(self, original: SweepCollection, decoded: SweepCollection) -> None: self.assertEqual(decoded.collection_id, original.collection_id) self.assertEqual(decoded.monotonic_ns, original.monotonic_ns) self.assertEqual(decoded.capture_start_ns, original.capture_start_ns) self.assertEqual(decoded.capture_end_ns, original.capture_end_ns) self.assertEqual(len(decoded.traces), len(original.traces)) for left, right in zip(original.traces, decoded.traces, strict=True): self.assertEqual((left.combo.input, left.combo.output), (right.combo.input, right.combo.output)) np.testing.assert_array_equal(left.frequency_hz, right.frequency_hz) np.testing.assert_array_equal(left.s11, right.s11) np.testing.assert_array_equal(left.s21, right.s21) def test_magic_matches_the_cpp_decoder(self) -> None: payload = serialize_trace_collection(_sweep(1), PREPROC_MAGIC) self.assertEqual(int.from_bytes(payload[:4], "little"), _CPP_PREPROCESSED_MAGIC) def test_round_trip_preserves_every_field(self) -> None: original = _sweep(42) self.assertTrue(self.writer.push(serialize_trace_collection(original, PREPROC_MAGIC))) decoded = self.reader.pop_preprocessed_collection() self.assertIsNotNone(decoded) assert decoded is not None self._assert_same(original, decoded) def test_batch_keeps_order(self) -> None: originals = [_sweep(cid) for cid in range(100, 105)] for collection in originals: self.assertTrue(self.writer.push(serialize_trace_collection(collection, PREPROC_MAGIC))) decoded = [] while (collection := self.reader.pop_preprocessed_collection()) is not None: decoded.append(collection) self.assertEqual([c.collection_id for c in decoded], [c.collection_id for c in originals]) for left, right in zip(originals, decoded, strict=True): self._assert_same(left, right) def test_overflow_drops_oldest(self) -> None: # Why the replay pushes in chunks instead of one burst: an undrained ring # silently overwrites, which would punch holes into the very image we are # trying to make coherent. capacity = 8 for cid in range(200, 200 + capacity + 4): self.writer.push(serialize_trace_collection(_sweep(cid), PREPROC_MAGIC)) survived = [] while (collection := self.reader.pop_preprocessed_collection()) is not None: survived.append(collection.collection_id) self.assertEqual(len(survived), capacity) self.assertEqual(survived[-1], 200 + capacity + 3) def _result_of(sweep: SweepCollection) -> ResultCollection: """The processor's result of a sweep: it keeps the sweep's id and timestamp.""" return replace(_bscan_result(sweep.collection_id), monotonic_ns=sweep.monotonic_ns) class _FakeProcessor: """Both ends of the processor during a feed-back. Each pushed sweep comes back as a result, after whatever the results ring already held when the feed-back started. """ slot_size_bytes = 1 << 20 def __init__(self, sweeps: list[SweepCollection], already_published: list[ResultCollection]) -> None: self._unprocessed = list(sweeps) self._published = list(already_published) def push(self, _payload: bytes) -> bool: self._published.append(_result_of(self._unprocessed.pop(0))) return True def pop_result_collection(self) -> ResultCollection | None: return self._published.pop(0) if self._published else None def close(self) -> None: return None class _IdleResultReader: """Stands in for a connected results reader that simply has nothing to hand out.""" def pop_result_collection(self): return None def close(self) -> None: return None class ReplayGuardTest(unittest.TestCase): """The GUI must refuse to write into a ring `data_preprocessor` still owns.""" def setUp(self) -> None: self.w = _window # The 50 ms ring poll and the debounce would both run against this half-faked # window while the test drives it by hand; park them for the duration. self.w._timer.stop() self.w._bscan_reprocess_timer.stop() self.addCleanup(self.w._timer.start) self.addCleanup(self.w._bscan_reprocess_timer.stop) self._original_is_running = self.w._supervisor.is_running self._original_is_processor_running = self.w._supervisor.is_processor_running self._original_mode = self.w._processing_mode.currentText() # Changing the mode fires the live-settings handler, which would kick off the # very replay these tests are inspecting. with QSignalBlocker(self.w._processing_mode): self.w._processing_mode.setCurrentText("bscan") self.w._supervisor.is_running = lambda: False self.w._supervisor.is_processor_running = lambda: True self.w._result_reader = _IdleResultReader() def tearDown(self) -> None: self.w._supervisor.is_running = self._original_is_running self.w._supervisor.is_processor_running = self._original_is_processor_running with QSignalBlocker(self.w._processing_mode): self.w._processing_mode.setCurrentText(self._original_mode) self.w._result_reader = None def test_refuses_while_acquisition_runs(self) -> None: self.w._supervisor.is_running = lambda: True self.assertFalse(self.w._can_reprocess_history()) def test_refuses_when_processor_is_down(self) -> None: self.w._supervisor.is_processor_running = lambda: False self.assertFalse(self.w._can_reprocess_history()) def test_refuses_outside_bscan_mode(self) -> None: self.w._processing_mode.setCurrentText("pass_through") self.assertFalse(self.w._can_reprocess_history()) def test_refuses_without_a_results_reader(self) -> None: self.w._result_reader = None self.assertFalse(self.w._can_reprocess_history()) def test_allows_when_stopped_with_a_live_processor(self) -> None: self.assertTrue(self.w._can_reprocess_history()) def test_replayed_results_are_rendered_instead_of_runtime_history(self) -> None: """Regression: 300 re-sent, 300 recovered, only 145 drawn. `_pre_history` and `_result_history` come from two rings that drop independently, so the re-sent sweeps only partly overlap the recorded results. The non-overlapping ones get appended to the deque under old ids, leaving its newest `window` entries a mix of freshly and stale-processed frames. Rendering from the replayed set instead sidesteps the whole problem. """ window = 30 replayed = [_bscan_result(cid) for cid in range(9000, 9000 + window)] self.w._result_history.clear() # Stand-in for a runtime history whose ids barely overlap the replayed ones. self.w._result_history.extend(_bscan_result(cid) for cid in range(1, 200)) self.addCleanup(self.w._result_history.clear) self.w._bscan_replay_results = replayed self.addCleanup(self.w._discard_bscan_replay_results) self.w._bscan_history_window.setValue(window) self.w._bscan_render_signature = None self.w._sync_bscan_history_from_results() self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window) def test_feed_back_renders_its_sweeps_in_acquisition_order(self) -> None: """Regression: after switching the displayed combo the columns came out rotated. The processor was still replaying its own sweeps when the GUI fed its history back, and the GUI took the first results to arrive for its own, in arrival order. """ sweeps = [_sweep(cid) for cid in range(1, 11)] self.w._pre_history.clear() self.w._pre_history.extend(sweeps) self.addCleanup(self.w._pre_history.clear) self.w._result_history.clear() self.addCleanup(self.w._result_history.clear) with QSignalBlocker(self.w._bscan_history_window): self.w._bscan_history_window.setValue(len(sweeps)) # Already in the results ring: the tail of the processor's own replay and a # result of a sweep that is not being fed back. processor = _FakeProcessor(sweeps, [_result_of(sweep) for sweep in sweeps[6:]] + [_bscan_result(999)]) self.w._replay_ring_writer = processor self.addCleanup(setattr, self.w, "_replay_ring_writer", None) self.w._result_reader = processor self.addCleanup(self.w._discard_bscan_replay_results) self.assertTrue(self.w._reprocess_history_through_processor()) self.assertEqual( [collection_key(collection) for collection in self.w._bscan_replay_results], [collection_key(sweep) for sweep in sweeps], ) self.assertIn(999, [collection.collection_id for collection in self.w._result_history]) def test_changing_the_displayed_combo_recomputes_nothing(self) -> None: # Every combo's B-scan is cached already; recomputing raced the processor (above). writes: list[dict] = [] self.w._write_live_processing_config = lambda **kwargs: writes.append(kwargs) self.addCleanup(delattr, self.w, "_write_live_processing_config") self.w._single_combo_output.editingFinished.emit() self.assertEqual(writes, []) self.assertFalse(self.w._bscan_reprocess_timer.isActive()) def test_processor_reprocesses_only_while_the_gui_cannot(self) -> None: # Its own replay would duplicate the feed-back and race it for the results ring. def written_flag() -> bool: self.w._write_live_processing_config() config = json.loads(self.w._live_config_writer.path.read_text(encoding="utf-8")) return bool(config["reprocess_current_result"]) self.assertFalse(written_flag()) self.w._supervisor.is_running = lambda: True self.assertTrue(written_flag()) def test_discarding_replay_falls_back_to_runtime_history(self) -> None: self.w._bscan_replay_results = [_bscan_result(1)] self.w._discard_bscan_replay_results() self.assertEqual(self.w._bscan_replay_results, []) self.assertIsNone(self.w._bscan_render_signature) def test_replay_refuses_to_re_enter_itself(self) -> None: # Pumping the event loop mid-replay can fire the debounce again; a nested burst # would share the ring and corrupt the result accounting. self.w._pre_history.clear() self.w._pre_history.extend(_sweep(cid) for cid in range(1, 6)) self.addCleanup(self.w._pre_history.clear) self.w._bscan_replay_active = True self.addCleanup(setattr, self.w, "_bscan_replay_active", False) self.assertFalse(self.w._reprocess_history_through_processor()) def test_replay_refuses_without_an_active_run_config(self) -> None: # A GUI restarted against a still-running processor does not know the ring # geometry, and ShmRingWriter would recreate the segment underneath it. self.w._pre_history.clear() self.w._pre_history.extend(_sweep(cid) for cid in range(1, 6)) self.addCleanup(self.w._pre_history.clear) previous = getattr(self.w, "_active_run_config", None) self.w._active_run_config = None self.addCleanup(setattr, self.w, "_active_run_config", previous) self.assertIsNone(self.w._ensure_replay_ring_writer()) self.assertFalse(self.w._reprocess_history_through_processor()) if __name__ == "__main__": unittest.main()