some fixes
This commit is contained in:
@@ -142,6 +142,14 @@ class AppWindowPipelineMixin:
|
|||||||
self._log("History reset because run settings changed")
|
self._log("History reset because run settings changed")
|
||||||
self._history_run_signature = run_signature
|
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._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture)
|
||||||
self._close_readers()
|
self._close_readers()
|
||||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||||
@@ -149,13 +157,16 @@ class AppWindowPipelineMixin:
|
|||||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||||
self._processor_run_signature = processor_signature
|
self._processor_run_signature = processor_signature
|
||||||
self._single_capture_active = single_capture
|
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_seen_raw = False
|
||||||
self._single_capture_target_collection_id = None
|
self._single_capture_target_collection_id = None
|
||||||
|
|
||||||
# Always drop unread payloads for all stages so single-capture starts
|
if not single_capture:
|
||||||
# from a clean boundary and does not retain stale results-only tail.
|
# Continuous start: discard any stale tail so rendering begins fresh.
|
||||||
self._drop_pending_ring_payloads(include_results=True)
|
# 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._last_reader_error_signature = None
|
||||||
self._reader_error_repeat_count = 0
|
self._reader_error_repeat_count = 0
|
||||||
# Record what to relaunch if a child later dies unexpectedly. Only a
|
# 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._active_run_config_path = config_path
|
||||||
self._pipeline_should_run = not single_capture
|
self._pipeline_should_run = not single_capture
|
||||||
self._pipeline_restart_count = 0
|
self._pipeline_restart_count = 0
|
||||||
if single_capture:
|
|
||||||
self._single_capture_start_ns = time.monotonic_ns()
|
|
||||||
|
|
||||||
pid_map = self._supervisor.pids()
|
pid_map = self._supervisor.pids()
|
||||||
pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none"
|
pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none"
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user