"""Unit tests for the Timepix device with a mocked backend.""" from __future__ import annotations import threading from types import SimpleNamespace from unittest import mock import numpy as np import ophyd import pytest from bec_server.device_server.tests.utils import DMMock from ophyd import DeviceStatus, StatusBase from ophyd_devices.tests.utils import MockPV, patch_dual_pvs from superxas_bec.devices.timepix.timepix import ( ACQUIRESTATUS, DATASOURCE, EXPOSUREMODE, FILEWRITEMODE, TRIGGERMODE, TRIGGERSOURCE, TDCEdge, TDCOuput, Timepix, ) from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_client import TimePixFlyStatus from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_interface import ( NetAddresses, PixelMap, ) # pylint: disable=protected-access # pylint: disable=redefined-outer-name class FakeBackendClient: """Backend client double that can finish status callbacks on demand.""" def __init__(self): self.status = TimePixFlyStatus.CONFIG self._status_callbacks = {} def add_status_callback(self, status, success, error, run=True): """Register status callback with optional immediate completion.""" if run and self.status in success: status.set_finished() return self._status_callbacks[id(status)] = (status, success, error) def emit_status(self, status_value: TimePixFlyStatus): """Resolve tracked status objects with a simulated backend state.""" self.status = status_value for cb_id, (status, success, error) in list(self._status_callbacks.items()): with status._lock: if status.done: self._status_callbacks.pop(cb_id, None) continue if status_value in success: status.set_finished() self._status_callbacks.pop(cb_id, None) elif status_value in error: status.set_exception(RuntimeError(f"backend entered {status_value.value}")) self._status_callbacks.pop(cb_id, None) def get_net_addresses(self): """Return a deterministic backend raw-data address.""" return NetAddresses( control="127.0.0.1:8452", address="127.0.0.1:8451", server="127.0.0.1:8080" ) class FakeBackend: """Minimal backend double used to isolate Timepix from backend integration.""" def __init__(self, *args, **kwargs): self.hostname = kwargs.get("hostname") or "localhost" self.socket_port = kwargs.get("socket_port", 9876) self.timepix_fly_client = FakeBackendClient() self.on_connected = mock.Mock() self.on_stage = mock.Mock() self.on_stop = mock.Mock() self.on_destroy = mock.Mock() self.add_callback = mock.Mock() self._trigger_status = StatusBase() self._trigger_status.set_finished() self._complete_status = StatusBase() self._complete_status.set_finished() def on_trigger(self): """Return a backend-prepared trigger status.""" return self._trigger_status def on_trigger_finished(self): """Return the status that resolves when acquisition is complete.""" return self._complete_status def on_complete(self, status=None): """Return a backend completion status.""" if status is None: return self._complete_status status.set_finished() return status def _finished_status(device=None): """Create a finished status for mocked signal set operations.""" status = DeviceStatus(device=device) status.set_finished() return status def _force_signal_value(signal, value): """Set a mocked PV-backed signal value, including read-only EPICS signals.""" if hasattr(signal, "_read_pv"): signal._read_pv.mock_data = value return signal.put(value) def _message_value(signal): """Extract the signal payload from a BEC message signal.""" msg = signal.get() return msg.signals[signal.name]["value"] @pytest.fixture(scope="function") def timepix(): """Timepix device with mocked EPICS signals and a fully mocked backend.""" backend = FakeBackend() scan_info = SimpleNamespace( msg=SimpleNamespace( scan_name="step_scan", scan_parameters={"exp_time": 0.1, "frames_per_trigger": 2}, num_points=3, scan_id="test", ) ) with ( mock.patch.object(ophyd, "cl") as mock_cl, mock.patch("superxas_bec.devices.timepix.timepix.TimepixFlyBackend", return_value=backend), ): mock_cl.get_pv = MockPV mock_cl.thread_class = threading.Thread dev = Timepix( name="timepix", prefix="X10DA-ES-TPX1:", backend_rest_url="localhost:8452", hostname="localhost", socket_port=9876, scan_info=scan_info, device_manager=DMMock(), ) patch_dual_pvs(dev) dev.backend = backend dev._poll_thread = mock.Mock() for walk in dev.walk_signals(): signal = walk.item if hasattr(signal, "set") and hasattr(signal, "put"): signal.set = mock.Mock(side_effect=lambda value, _sig=signal, **_kw: (_force_signal_value(_sig, value), _finished_status(_sig))[1]) # type: ignore[method-assign] yield dev dev._poll_thread_kill_event.set() @pytest.fixture(scope="function") def pixel_map(): """Small valid pixel map used for stage tests.""" return PixelMap(chips=[[{"i": 0, "p": 0, "f": [1.0]}]]) def test_timepix_on_connected_configures_signals_and_registers_callback(timepix): """Connected hook should configure camera, file writer, and backend callback.""" timepix.on_connected() assert timepix.cam.tdc1_enable.get() == 1 assert timepix.cam.tdc2_enable.get() == 1 assert timepix.cam.raw_enable.get() == 1 assert timepix.cam.tdc1_edge.get() == TDCEdge.RISING assert timepix.cam.tdc1_output.get() == TDCOuput.ALL_CHANNELS assert timepix.cam.trigger_mode.get() == TRIGGERMODE.INTERNAL assert timepix.cam.trigger_source.get() == TRIGGERSOURCE.HDMI1_1 assert timepix.cam.exposure_mode.get() == EXPOSUREMODE.TIMED assert timepix.cam.array_counter.get() == 0 assert timepix.hdf.enable.get() == "1" assert timepix.hdf.file_write_mode.get() == FILEWRITEMODE.STREAM.value assert timepix.hdf.auto_save.get() == 1 assert timepix.cam.array_callbacks.get() == 1 timepix.backend.add_callback.assert_called_once_with(timepix.msg_buffer_callback) timepix._poll_thread.start.assert_called_once() def test_timepix_on_stage_configures_camera_writer_and_mocked_backend(timepix, pixel_map): """Stage should configure camera settings and forward config to the backend.""" timepix._pixel_map = pixel_map with ( mock.patch( "superxas_bec.devices.timepix.timepix.get_full_path", return_value="/tmp/timepix/test_scan.h5", ), ): _force_signal_value(timepix.hdf.enable, "Enable") timepix.on_stage() assert timepix.cam.acquire_time.get() == pytest.approx(0.1 - timepix._readout_time) assert timepix.cam.acquire_period.get() == pytest.approx(0.1) assert timepix.cam.num_images.get() == 2 assert timepix.cam.data_source.get() == DATASOURCE.IMAGE assert timepix.hdf.file_path.get() == "/tmp/timepix" assert timepix.hdf.file_name.get() == "test_scan.h5" assert timepix.hdf.num_capture.get() == 6 assert timepix.hdf.capture.get() == 1 assert timepix.cam.raw_file_template.get() == "" assert timepix.cam.raw_file_path.get() == "tcp://connect@127.0.0.1:8451" assert timepix.backend.on_stage.call_count == 1 other_config = timepix.backend.on_stage.call_args.kwargs["other_config"] assert ( other_config.output_uri == "redis://localhost:0000/public/timepix_fly_backend/raw_data?scan-id=test" ) assert other_config.TRoiStep == timepix.troistep assert other_config.TRoiN == timepix.troin assert timepix.backend.on_stage.call_args.kwargs["pixel_map"] == pixel_map file_event = timepix.file_event.get() assert file_event.file_path == "/tmp/timepix/test_scan.h5" assert file_event.done is False assert file_event.successful is False def test_timepix_on_stage_configures_mocked_backend(timepix, pixel_map): """Stage should configure camera settings and forward config to the backend.""" timepix._pixel_map = pixel_map with ( mock.patch( "superxas_bec.devices.timepix.timepix.get_full_path", return_value="/tmp/timepix/test_scan.h5", ), ): _force_signal_value(timepix.hdf.enable, "Disable") # File event should not be emitted when hdf.enable is "Disable" with mock.patch.object(timepix.file_event, "put") as mock_file_event_put: timepix.hdf.capture.put(0) timepix.on_stage() mock_file_event_put.assert_not_called() assert timepix.hdf.capture.get() == 0 assert timepix.cam.acquire_time.get() == pytest.approx(0.1 - timepix._readout_time) assert timepix.cam.acquire_period.get() == pytest.approx(0.1) assert timepix.cam.num_images.get() == 2 assert timepix.cam.data_source.get() == DATASOURCE.IMAGE assert timepix.cam.raw_file_template.get() == "" assert timepix.cam.raw_file_path.get() == "tcp://connect@127.0.0.1:8451" assert timepix.backend.on_stage.call_count == 1 other_config = timepix.backend.on_stage.call_args.kwargs["other_config"] assert ( other_config.output_uri == "redis://localhost:0000/public/timepix_fly_backend/raw_data?scan-id=test" ) assert other_config.TRoiStep == timepix.troistep assert other_config.TRoiN == timepix.troin assert timepix.backend.on_stage.call_args.kwargs["pixel_map"] == pixel_map def test_timepix_on_stage_without_xes_skips_backend_configuration(timepix): """When XES is disabled, the backend-specific stage call should be skipped.""" timepix.enable_xes = False with mock.patch( "superxas_bec.devices.timepix.timepix.get_full_path", return_value="/tmp/timepix/no_xes_scan.h5", ): timepix.on_stage() timepix.backend.on_stage.assert_not_called() def test_timepix_on_trigger_combines_camera_and_backend_status(timepix): """Trigger should arm the backend first and then drive the camera.""" backend_done = StatusBase() backend_done.set_finished() backend_finished = StatusBase() timepix.backend._trigger_status = backend_done timepix.backend._complete_status = backend_finished status = timepix.on_trigger() assert isinstance(status, StatusBase) assert timepix.cam.acquire.get() == 1 _force_signal_value(timepix.cam.acquire_busy, ACQUIRESTATUS.ACQUIRING) _force_signal_value(timepix.cam.acquire_busy, ACQUIRESTATUS.DONE) backend_finished.set_finished() status.wait(timeout=0.1) assert status.done is True assert status.success is True def test_timepix_on_complete(timepix): """Complete should wait for writer/backend completion and emit a success file event.""" # I. Case I. With hdf.enable "Enable", should wait for both writer and camera to be done before emitting file event timepix.hdf.enable.put("Enable") timepix._full_path = "/tmp/timepix/final_scan.h5" timepix._n_images = 3 _force_signal_value(timepix.cam.acquire_busy, ACQUIRESTATUS.ACQUIRING) timepix.hdf.capture.put(ACQUIRESTATUS.ACQUIRING) timepix.hdf.write_file.put(ACQUIRESTATUS.ACQUIRING) status = timepix.on_complete() _force_signal_value(timepix.cam.acquire_busy, ACQUIRESTATUS.DONE) timepix.hdf.capture.put(ACQUIRESTATUS.DONE) timepix.hdf.write_file.put(ACQUIRESTATUS.DONE) timepix.hdf.write_status.put(0) _force_signal_value(timepix.hdf.num_captured, 3) status.wait(timeout=1) file_event = timepix.file_event.get() assert file_event.file_path == "/tmp/timepix/final_scan.h5" assert file_event.done is True assert file_event.successful is True # II. Case II. With hdf.enable "Disable", should not wait for writer and only wait for camera to be done before emitting file event timepix.hdf.enable.put("Disable") timepix._n_images = 3 _force_signal_value(timepix.cam.acquire_busy, ACQUIRESTATUS.ACQUIRING) timepix.hdf.capture.put(ACQUIRESTATUS.ACQUIRING) timepix.hdf.write_file.put(ACQUIRESTATUS.ACQUIRING) with mock.patch.object(timepix.file_event, "put") as mock_file_event_put: status = timepix.on_complete() _force_signal_value(timepix.cam.acquire_busy, ACQUIRESTATUS.DONE) status.wait(timeout=1) mock_file_event_put.assert_not_called() assert status.done is True assert status.success is True def test_timepix_msg_buffer_callback_updates_xes_signals(timepix): """The backend message callback should populate all exported XES data signals.""" start_frame = { "type": "StartFrame", "Mode": "TOA", "TRoiStart": 0, "TRoiStep": 1, "TRoiN": 2, "NumEnergyPoints": 8, "save_interval": 1, } data_frames = [ { "type": "XesData", "period": 131000, "totalEvents": 36, "TDSpectra": list(range(16)), "beforeROI": 0, "afterROI": 0, } ] end_frame = {"type": "EndFrame", "error": "", "periods": 4} timepix.troin = 2 timepix.msg_buffer_callback(start_frame, data_frames, end_frame) expected_xes = np.array( [[0, 1, 2, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, 13, 14, 15]], dtype=np.float32 ) np.testing.assert_array_equal(_message_value(timepix.xes_data), expected_xes) np.testing.assert_array_equal( _message_value(timepix.xes_spectra), np.array([28, 92], dtype=np.float32) ) np.testing.assert_array_equal( _message_value(timepix.xes_energy_1), np.array([6, 38], dtype=np.float32) ) np.testing.assert_array_equal( _message_value(timepix.xes_energy_2), np.array([22, 54], dtype=np.float32) ) assert _message_value(timepix.total_periods) == pytest.approx(4 / 131000) np.testing.assert_array_equal(_message_value(timepix.tds_period), np.array([1.0])) assert _message_value(timepix.total_events) == 36 def test_timepix_on_stop_stops_camera_writer_and_backend(timepix): """Stop should stop camera acquisition and delegate backend stop.""" timepix.cam.acquire.put(1) timepix.hdf.capture.put(1) timepix.on_stop() assert timepix.cam.acquire.get() == 0 assert timepix.hdf.capture.get() == 0 timepix.backend.on_stop.assert_called_once() def test_timepix_on_destroy_cleans_up_backend(timepix): """Destroy should stop polling and forward cleanup to the backend.""" timepix.on_destroy() assert timepix._poll_thread_kill_event.is_set() is True timepix.backend.on_stop.assert_called_once() timepix.backend.on_destroy.assert_called_once() def test_timepix_on_prescan_returns_correct_status(timepix): """Prescan should return a finished status if the backend is ready.""" # Case I. With hdf.enable "Enable", should wait for both writer and camera to be ready timepix.hdf.enable._read_pv.mock_data = "Enable" timepix.cam.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.ACQUIRING timepix.hdf.capture._read_pv.mock_data = ACQUIRESTATUS.DONE # Should be combined status of writer and control status = timepix.on_pre_scan() assert status.done is False timepix.cam.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.DONE assert status.done is False timepix.hdf.capture._read_pv.mock_data = ACQUIRESTATUS.ACQUIRING status.wait(timeout=1) assert status.done is True assert status.success is True # Case II. With hdf.enable "Enable", should wait for both writer and camera to be ready timepix.hdf.enable._read_pv.mock_data = "Disable" timepix.cam.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.ACQUIRING status = timepix.on_pre_scan() assert status.done is False timepix.cam.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.DONE status.wait(timeout=1) assert status.done is True assert status.success is True