"""Module for testing the PandaBoxCSAXS and PandaBoxOMNY devices.""" # pylint: skip-file from __future__ import annotations import threading from unittest import mock import numpy as np import pytest from ophyd import Staged from ophyd_devices.devices.panda_box.panda_box import PandaState from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError from pandablocks.responses import FrameData from csaxs_bec.devices.panda_box.panda_box import PandaBoxCSAXS from csaxs_bec.devices.panda_box.panda_box_omny import PandaBoxOMNY from csaxs_bec.devices.utils.utils import fetch_scan_info def _make_frame_data(n_rows: int, start: int = 0) -> FrameData: """Build a synthetic FrameData batch with `n_rows` rows for the frame_counter / gate_detector_active signals used by the raw-stream tests below.""" data = np.array( [(np.float64(i), np.float64(i % 2)) for i in range(start, start + n_rows)], dtype=[("COUNTER1.OUT.Value", "= 5, flush triggered out = mock_put.call_args.args[0] assert [v for v in out["frame_counter"]["value"]] == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] assert [v for v in out["gate_detector_active"]["value"]] == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] def test_panda_omny_raw_stream_gate_bit_is_masked_from_shared_word(panda_omny_raw_stream): """PCAP.BITS0 is a 32-bit word shared with other captured bit_out signals; only bit 0 (TTLIN1.VAL, confirmed via CAPTURE_WORD?/OFFSET? against the omny-panda hardware) is the detector-trigger gate. gate_detector_active must be masked down to that bit rather than passed through as the raw word.""" dev = panda_omny_raw_stream data = np.array( # bit 0 set (gate active) plus other bits from something else sharing BITS0 [(0.0, 5.0), (1.0, 4.0), (2.0, 3.0)], dtype=[("COUNTER1.OUT.Value", " threading.Timer: """Simulate the PandA data thread delivering the READY event `delay` seconds from now.""" timer = threading.Timer(delay, dev._run_status_callbacks, args=(PandaState.READY,)) timer.daemon = True timer.start() return timer def test_panda_omny_pre_scan_arms_when_ready(panda_omny, capsys): dev = panda_omny dev.pre_scan_timeout = 2 with ( mock.patch.object(dev, "_arm") as mock_arm, mock.patch.object(dev, "_reset_panda") as mock_reset, ): _deliver_ready_after(dev, 0.05) status = dev.on_pre_scan() status.wait(timeout=4) assert status.success is True mock_arm.assert_called_once() mock_reset.assert_not_called() assert "Forcing abort/reset" not in capsys.readouterr().out def test_panda_omny_pre_scan_retries_once_then_succeeds(panda_omny, capsys): """No READY on the first attempt: the box is force-reset and arming retried. The retry is printed and logged.""" dev = panda_omny dev.pre_scan_timeout = 0.2 def _reset_then_ready(): _deliver_ready_after(dev, 0.05) with ( mock.patch.object(dev, "_arm") as mock_arm, mock.patch.object(dev, "_reset_panda", side_effect=_reset_then_ready) as mock_reset, mock.patch("csaxs_bec.devices.panda_box.panda_box_omny.logger") as mock_logger, ): status = dev.on_pre_scan() status.wait(timeout=4) assert status.success is True mock_reset.assert_called_once() mock_arm.assert_called_once() assert any("Forcing abort/reset" in str(c) for c in mock_logger.warning.call_args_list) assert any("armed successfully on attempt 2" in str(c) for c in mock_logger.info.call_args_list) assert "Forcing abort/reset" in capsys.readouterr().out def test_panda_omny_pre_scan_raises_after_retry_fails(panda_omny, capsys): dev = panda_omny dev.pre_scan_timeout = 0.1 with ( mock.patch.object(dev, "_arm") as mock_arm, mock.patch.object(dev, "_reset_panda") as mock_reset, mock.patch("csaxs_bec.devices.panda_box.panda_box_omny.logger") as mock_logger, ): status = dev.on_pre_scan() with pytest.raises(RuntimeError, match="could not be armed after 2 attempts"): status.wait(timeout=4) mock_arm.assert_not_called() # once for the forced reset before the retry, once to leave the box clean at the end assert mock_reset.call_count == 2 mock_logger.error.assert_called_once() assert capsys.readouterr().out.count("Forcing abort/reset") == 1 # stale status callbacks of the failed attempts are removed again assert dev._status_callbacks == {} def test_panda_omny_pre_scan_stop_is_not_retried(panda_omny, capsys): dev = panda_omny dev.pre_scan_timeout = 0.3 with ( mock.patch.object(dev, "_arm") as mock_arm, mock.patch.object(dev, "_reset_panda") as mock_reset, ): status = dev.on_pre_scan() threading.Event().wait(0.05) dev._stop_stoppable_status_objects() with pytest.raises(DeviceStoppedError): status.wait(timeout=4) # give a wrongly retrying task thread the time to show up threading.Event().wait(0.5) mock_arm.assert_not_called() mock_reset.assert_not_called() assert "Forcing abort/reset" not in capsys.readouterr().out def test_panda_omny_raw_stream_drops_stale_leading_frame_counter_rows(panda_omny_raw_stream): """PandA's COUNTER1.OUT is not cleared by Arm(), so the first raw samples of a new acquisition can briefly carry a stale frame_counter value left over from the previous acquisition's tail (confirmed 2026-09-21 against a real scan, S06313). Rows before the first observed frame_counter == 0 must be dropped, and every channel must be truncated at the same row so they stay aligned.""" dev = panda_omny_raw_stream # Two stale rows carrying the previous acquisition's terminal counter value (167), then # the real acquisition starting at frame_counter 0. data = np.array( [(167.0, 1.0), (167.0, 0.0), (0.0, 1.0), (0.0, 0.0), (1.0, 1.0)], dtype=[("COUNTER1.OUT.Value", "