283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""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
|
|
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.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 = AppWindow(Path("."))
|
|
|
|
|
|
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)
|
|
|
|
|
|
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_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()
|