diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 6edae98..2806cbf 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -142,6 +142,14 @@ class AppWindowPipelineMixin: self._log("History reset because run settings changed") self._history_run_signature = run_signature + # Open the single-capture acquisition window BEFORE the producer can emit: + # any collection the one-shot orchestrator publishes is then strictly newer + # than this timestamp and is recognized as the target. Reading the clock + # AFTER start() raced a fast (simulator) orchestrator that had already + # published — its collection.monotonic_ns < start_ns, so the capture never + # completed and the GUI hung. + single_capture_start_ns = time.monotonic_ns() if single_capture else None + self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture) self._close_readers() self._raw_reader = ShmRingReader(config.rings.raw_tap.name) @@ -149,13 +157,16 @@ class AppWindowPipelineMixin: self._result_reader = ShmRingReader(config.rings.results.name) self._processor_run_signature = processor_signature self._single_capture_active = single_capture - self._single_capture_start_ns = None + self._single_capture_start_ns = single_capture_start_ns self._single_capture_seen_raw = False self._single_capture_target_collection_id = None - # Always drop unread payloads for all stages so single-capture starts - # from a clean boundary and does not retain stale results-only tail. - self._drop_pending_ring_payloads(include_results=True) + if not single_capture: + # Continuous start: discard any stale tail so rendering begins fresh. + # Single capture must NOT drop here — the drop races with (and can + # discard) the one-shot result; it instead filters stale data by the + # start timestamp recorded above. + self._drop_pending_ring_payloads(include_results=True) self._last_reader_error_signature = None self._reader_error_repeat_count = 0 # Record what to relaunch if a child later dies unexpectedly. Only a @@ -164,8 +175,6 @@ class AppWindowPipelineMixin: self._active_run_config_path = config_path self._pipeline_should_run = not single_capture self._pipeline_restart_count = 0 - if single_capture: - self._single_capture_start_ns = time.monotonic_ns() pid_map = self._supervisor.pids() pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none" diff --git a/python_app/tests/test_single_capture.py b/python_app/tests/test_single_capture.py new file mode 100644 index 0000000..f628902 --- /dev/null +++ b/python_app/tests/test_single_capture.py @@ -0,0 +1,94 @@ +"""Single-capture completion logic. + +The single capture starts a one-shot pipeline and must finish on exactly the result +that belongs to its sweep. Because collection ids restart per orchestrator run, a stale +result from a previous run can carry the SAME id; the start timestamp disambiguates them. +These tests pin that boundary (fresh vs stale same-id) and the readiness state machine — +the path that previously hung on the simulator when start_ns was recorded too late. +""" + +from __future__ import annotations + +import os +import unittest +from pathlib import Path + +import numpy as np + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +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, +) + +_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 _result(collection_id: int, monotonic_ns: int) -> ResultCollection: + payload = ResultPayload( + processing_name="pass_through", kind=1, + frequency_hz=np.array([1.0, 2.0], dtype=np.float32), + trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64), + ) + block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload]) + return ResultCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, blocks=[block]) + + +class SingleCaptureBoundaryTest(unittest.TestCase): + def setUp(self) -> None: + self.w = _window + self.w._processing_mode.setCurrentText("pass_through") + self.w._single_capture_active = True + self.w._single_capture_start_ns = 1000 + self.w._single_capture_seen_raw = True + self.w._single_capture_target_collection_id = 5 + self.w._result_history.clear() + + def test_picks_fresh_result_not_stale_same_id(self) -> None: + self.w._result_history.append(_result(5, 500)) # stale: same id, before start_ns + self.w._result_history.append(_result(5, 1500)) # fresh: at/after start_ns + target = self.w._find_single_capture_target_result() + self.assertIsNotNone(target) + self.assertEqual(target.monotonic_ns, 1500) + + def test_stale_same_id_result_is_not_matched(self) -> None: + self.w._result_history.append(_result(5, 500)) # only a stale same-id result + self.assertIsNone(self.w._find_single_capture_target_result()) + + def test_wrong_collection_id_is_not_matched(self) -> None: + self.w._result_history.append(_result(9, 1500)) # fresh but a different sweep + self.assertIsNone(self.w._find_single_capture_target_result()) + + def test_not_ready_until_a_fresh_raw_is_seen(self) -> None: + # The readiness guard that the timing fix makes reachable: with no raw recognized + # as fresh yet, the capture must not complete (it must keep waiting, not hang). + self.w._single_capture_seen_raw = False + self.w._result_history.append(_result(5, 1500)) + self.assertFalse(self.w._finish_single_capture_if_ready()) + self.assertTrue(self.w._single_capture_active) + + def test_completes_and_stops_on_the_fresh_result(self) -> None: + self.w._result_history.append(_result(5, 1500)) + self.assertTrue(self.w._finish_single_capture_if_ready()) + self.assertFalse(self.w._single_capture_active) # _stop_run cleared the flag + + +if __name__ == "__main__": + unittest.main()