248 lines
11 KiB
Python
248 lines
11 KiB
Python
"""Full-history replay uses exact acquisition keys and owns its ring lifecycle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
import os
|
|
import struct
|
|
from pathlib import Path
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
import numpy as np
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
|
from python_app.gui.controllers.bscan_replay import BscanReplay
|
|
from python_app.gui.controllers.history_controller import HistoryController
|
|
from python_app.models.collection_identity import collection_key
|
|
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection, TraceData
|
|
from python_app.models.run_config_schema import RingEndpointModel
|
|
from python_app.orchestration.shm.decoder import PREPROC_MAGIC, RESULT_MAGIC, decode_trace_collection
|
|
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
|
|
|
|
_app: QApplication | None = None
|
|
|
|
|
|
def setUpModule() -> None:
|
|
global _app
|
|
instance = QApplication.instance()
|
|
_app = instance if isinstance(instance, QApplication) else QApplication([])
|
|
|
|
|
|
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),
|
|
s21=(np.arange(points) * 2 + index).astype(np.complex64) - 0.25j,
|
|
capture_start_ns=collection_id * 10 + index,
|
|
capture_end_ns=collection_id * 10 + index + 1,
|
|
) for index in range(combos)]
|
|
return SweepCollection(collection_id, collection_id * 1_000_000 + 7, traces,
|
|
capture_start_ns=collection_id * 10, capture_end_ns=collection_id * 10 + 5)
|
|
|
|
|
|
def _result_of(sweep: SweepCollection) -> ResultCollection:
|
|
return ResultCollection(sweep.collection_id, sweep.monotonic_ns + 100,
|
|
capture_start_ns=sweep.capture_start_ns, capture_end_ns=sweep.capture_end_ns, settings_revision=7)
|
|
|
|
|
|
|
|
|
|
class _ProcessorReader(ShmRingReader):
|
|
"""Boundary stand-in: decode pushed wire bytes and queue processor results."""
|
|
|
|
def __init__(self) -> None:
|
|
self.published: list[ResultCollection] = []
|
|
self.received: list[SweepCollection] = []
|
|
self.wait_once = False
|
|
|
|
@property
|
|
def capacity(self) -> int:
|
|
return 50
|
|
|
|
def push(self, payload: bytes) -> bool:
|
|
sweep = decode_trace_collection(payload, PREPROC_MAGIC)
|
|
self.received.append(sweep)
|
|
self.published.append(_result_of(sweep))
|
|
return True
|
|
|
|
def pop_result_collection(self) -> ResultCollection | None:
|
|
if self.wait_once:
|
|
self.wait_once = False
|
|
return None
|
|
return self.published.pop(0) if self.published else None
|
|
|
|
|
|
class ReplayTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.history = HistoryController()
|
|
self.warnings: list[str] = []
|
|
self.replay = BscanReplay(self.history, lambda: None, self.warning, lambda _text: None)
|
|
self.addCleanup(self.replay.close)
|
|
self.ring = RingEndpointModel("/unused_test_replay", 50, 1 << 20)
|
|
self.processor = _ProcessorReader()
|
|
self.writer = Mock(spec=ShmRingWriter, push=self.processor.push, slot_size_bytes=1 << 20)
|
|
writer_patch = patch(
|
|
"python_app.gui.controllers.bscan_replay.ShmRingWriter", return_value=self.writer,
|
|
)
|
|
self.writer_factory = writer_patch.start()
|
|
self.addCleanup(writer_patch.stop)
|
|
|
|
def warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
|
self.warnings.append(text)
|
|
|
|
def run_replay(self) -> bool:
|
|
return self.replay.run(window=300, revision=7, ring=self.ring, reader=self.processor,
|
|
continue_replay=lambda: True, pump_events=lambda _pause: None)
|
|
|
|
def test_result_ring_capacity_bounds_each_replay_burst(self) -> None:
|
|
name = f"/radar_replay_small_result_{os.getpid()}"
|
|
self.addCleanup((Path("/dev/shm") / name[1:]).unlink, missing_ok=True)
|
|
results = ShmRingWriter(name, 2, 256)
|
|
self.addCleanup(results.close)
|
|
reader = ShmRingReader(name)
|
|
self.addCleanup(reader.close)
|
|
|
|
def process(payload: bytes) -> bool:
|
|
sweep = decode_trace_collection(payload, PREPROC_MAGIC)
|
|
return results.push(struct.pack("<I6QfII", RESULT_MAGIC, sweep.collection_id,
|
|
sweep.monotonic_ns, 0, 7, sweep.capture_start_ns,
|
|
sweep.capture_end_ns, 0., 0, 0))
|
|
|
|
self.writer.push = Mock(side_effect=process)
|
|
self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 7))
|
|
self.assertTrue(self.replay.run(
|
|
window=6, revision=7, ring=self.ring, reader=reader,
|
|
continue_replay=lambda: True, pump_events=lambda _pause: None,
|
|
))
|
|
self.assertEqual([item.collection_id for item in self.history.replay_results], list(range(1, 7)))
|
|
self.assertEqual(self.warnings, [])
|
|
|
|
def test_replays_full_history_in_capture_order_despite_independent_ring_drops(self) -> None:
|
|
sweeps = [_sweep(cid) for cid in range(1, 301)]
|
|
self.history.preprocessed.extend(sweeps)
|
|
self.history.results.extend(_result_of(_sweep(cid)) for cid in range(300, 450))
|
|
# Old processor replay arrives before the GUI's own results, including a
|
|
# restarted collection id whose capture key differs from the requested one.
|
|
unrelated = _result_of(replace(_sweep(1), capture_start_ns=9000, capture_end_ns=9005))
|
|
stale_tail = replace(_result_of(sweeps[-1]), settings_revision=6)
|
|
self.processor.published = [stale_tail, *[_result_of(sweep) for sweep in sweeps[6:10]], unrelated]
|
|
self.assertTrue(self.run_replay())
|
|
source = self.history.bscan_source()
|
|
self.assertEqual([collection_key(item) for item in source], [collection_key(item) for item in sweeps])
|
|
self.assertEqual(len(self.processor.received), 300)
|
|
self.assertTrue(all(item.settings_revision == 7 for item in source))
|
|
self.assertIsNot(source[-1], stale_tail)
|
|
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_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,
|
|
continue_replay=lambda: True, pump_events=lambda _pause: None))
|
|
self.assertFalse(self.replay.run(window=1, revision=7, ring=None, reader=self.processor,
|
|
continue_replay=lambda: True, pump_events=lambda _pause: None))
|
|
self.writer_factory.assert_not_called()
|
|
self.assertIn("geometry is unknown", self.warnings[-1])
|
|
|
|
def test_nested_replay_is_refused(self) -> None:
|
|
self.history.preprocessed.append(_sweep(1))
|
|
self.processor.wait_once = True
|
|
nested: list[bool] = []
|
|
|
|
def pump(_pause: float) -> None:
|
|
nested.append(self.run_replay())
|
|
|
|
self.assertTrue(self.replay.run(window=1, revision=7, ring=self.ring, reader=self.processor,
|
|
continue_replay=lambda: True, pump_events=pump))
|
|
self.assertEqual(nested, [False])
|
|
self.assertFalse(self.replay.active)
|
|
|
|
def test_close_discard_or_settings_edit_during_pump_cancels_current_replay(self) -> None:
|
|
self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 25))
|
|
for action in ("close", "discard", "schedule"):
|
|
with self.subTest(action=action):
|
|
self.processor.published.clear()
|
|
self.processor.received.clear()
|
|
self.processor.wait_once = True
|
|
|
|
def pump(_pause: float) -> None:
|
|
if action == "schedule":
|
|
self.replay.schedule(True)
|
|
elif action == "close":
|
|
self.replay.close()
|
|
else:
|
|
self.replay.discard()
|
|
self.assertFalse(self.run_replay())
|
|
|
|
self.assertFalse(self.replay.run(
|
|
window=24, revision=7, ring=self.ring, reader=self.processor,
|
|
continue_replay=lambda: True, pump_events=pump,
|
|
))
|
|
self.assertLess(len(self.processor.received), 24)
|
|
self.assertEqual(list(self.history.results), [])
|
|
self.assertEqual(self.history.replay_results, [])
|
|
self.assertFalse(self.replay.active)
|
|
self.assertEqual(self.replay.timer.isActive(), action == "schedule")
|
|
|
|
def test_runtime_guard_is_rechecked_before_io_and_after_event_pumping(self) -> None:
|
|
self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 25))
|
|
for stop_at in ("push", "pop", "pump"):
|
|
with self.subTest(stop_at=stop_at):
|
|
self.processor.published.clear()
|
|
self.processor.received.clear()
|
|
self.processor.wait_once = stop_at == "pump"
|
|
running = True
|
|
|
|
def guard() -> bool:
|
|
count = len(self.processor.received)
|
|
return running and not (stop_at == "push" and count >= 1
|
|
or stop_at == "pop" and count >= 12)
|
|
|
|
def pump(_pause: float) -> None:
|
|
nonlocal running
|
|
running = False
|
|
|
|
self.assertFalse(self.replay.run(
|
|
window=24, revision=7, ring=self.ring, reader=self.processor,
|
|
continue_replay=guard, pump_events=pump,
|
|
))
|
|
self.assertEqual(len(self.processor.received), 1 if stop_at == "push" else 12)
|
|
self.assertEqual(list(self.history.results), [])
|
|
self.assertEqual(self.history.replay_results, [])
|
|
self.assertFalse(self.replay.active)
|
|
|
|
def test_small_ring_is_drained_before_another_burst(self) -> None:
|
|
self.ring.capacity = 2
|
|
self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 16))
|
|
|
|
def push(payload: bytes) -> bool:
|
|
self.assertLess(len(self.processor.published), self.ring.capacity)
|
|
return self.processor.push(payload)
|
|
|
|
self.writer.push = push
|
|
self.assertTrue(self.run_replay())
|
|
self.assertEqual(len(self.history.replay_results), 15)
|
|
|
|
def test_discard_and_close_release_replay_source_timer_and_writer(self) -> None:
|
|
self.history.preprocessed.append(_sweep(1))
|
|
self.assertTrue(self.run_replay())
|
|
self.replay.discard()
|
|
self.assertEqual(self.history.bscan_source(), list(self.history.results))
|
|
self.replay.schedule(False)
|
|
self.assertFalse(self.replay.timer.isActive())
|
|
self.replay.schedule(True)
|
|
self.assertTrue(self.replay.timer.isActive())
|
|
self.replay.close()
|
|
self.replay.close()
|
|
self.assertFalse(self.replay.timer.isActive())
|
|
self.writer.close.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|