diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_User_abstract_instrument_server.md similarity index 98% rename from docs/how-to/4_Use_abstract_instrument_server.md rename to docs/how-to/4_User_abstract_instrument_server.md index 3fdd2dfb..8de42bd1 100644 --- a/docs/how-to/4_Use_abstract_instrument_server.md +++ b/docs/how-to/4_User_abstract_instrument_server.md @@ -70,7 +70,7 @@ class MyMotorServer(AbstractInstrumentServer): self._send_response(b"Moved to " + position) if __name__ == "__main__": # Initialize and start the server - server = MyInstrumentServer("127.0.0.1", 5000) + server = MyMotorServer("127.0.0.1", 5000) try: server.start() except KeyboardInterrupt: diff --git a/docs/how-to/4a_Zurick_lockin_amplifier.md b/docs/how-to/4a_Zurick_lockin_amplifier.md new file mode 100644 index 00000000..b1ff74b7 --- /dev/null +++ b/docs/how-to/4a_Zurick_lockin_amplifier.md @@ -0,0 +1,76 @@ +# Zurick lockin amplifier(HF2Server) + +A TCP-based gateway for Zurich Instruments HF2 series lock-in amplifiers. This server allows remote clients to control hardware nodes and acquire averaged data via a simple Tab-Separated Protocol. + +## 📡 Network Protocol + +The server uses a **Tab-Separated Protocol** over TCP. + +**Request Format:** +`command` + `\t` + `arg1` + `\t` + `arg2` ... + `\n` + +**Response Format:** +* `1\t[Data]\n` : **Success**. +* `0\t[Error Message]\n` : **Failure**. + +# HF2Server Reference + +A TCP-based gateway for Zurich Instruments HF2 series lock-in amplifiers. This server allows remote clients to control hardware nodes and acquire averaged data via a simple Tab-Separated Protocol. + +## 📡 Network Protocol + +**Request Format:** `command` + `\t` + `arg1` + `\t` + `arg2` ... + `\n` +**Response Format:** `1\t[Data]\n` (Success) or `0\t[Error Message]\n` (Failure) + +## 📖 Complete Command Reference + +| Command | Arguments | Description | +| :--- | :--- | :--- | +| **Data Acquisition** | | | +| `getData` | `duration` (float) | Returns: `x, y, theta, scope_mean, r` | +| `setupScope` | `freq`, `len`, `ch` | Configures Scope for single shot (Time, Length, Input Select). | +| **Oscillator & Output** | | | +| `setRefFreq` | `val` (float) | Sets Lockin reference Frequency (Hz). | +| `setRefV` | `val` (float) | Sets reference voltage Amplitude (Vpk). | +| `setRefVoff` | `val` (float) | Sets Signal voltage Offset (V). | +| `setsRefOutSwitch`| `state` (0/1) | Enables (1) or Disables (0) Signal Output 0. | +| **Demodulator Settings** | | | +| `setTimeConstant` | `val` (float) | Sets Lockin (low pass) Time Constant (s). | +| `setDataRate` | `val` (float) | Sets Demodulator 0 Sample Rate (Hz). | +| `setsRefHarm` | `val` (int) | Sets Demodulator Harmonic. | +| **Input & Autorange** | | | +| `setCurrentInRange`| `val` (float) | Sets Current Input Range (Powers of 10). | +| `autoCurrentInRange`| None | Triggers Autorange for Current Input 0. | +| `autoVoltageInRange`| None | Triggers Autorange for Signal Input 0. | +| **System** | | | +| `ping` | None | Returns `1\t` if server is alive. | +| `connect_hardware`| None | Re-establishes connection to ZI Data Server. | +| `disconnect_hardware`| None | Safely disconnects from hardware. | +| `shutdown` | None | Stops the server and disconnects hardware. | +## 💻 Example Usage + +### 1. Start the Server +```python +from sm_bluesky.common.server import HF2Server + +server = HF2Server( + host="0.0.0.0", + port=7891, + device_id="dev4206", + hf2_ip="172.23.110.84" +) +server.start() +``` +### 2. Client + +import socket +```python + +def query_hf2(command): + with socket.create_connection(("localhost", 7891)) as sock: + sock.sendall(f"{command}\n".encode()) + return sock.recv(1024).decode() + +# Example: Get 0.5s of averaged data +print(query_hf2("getData\t0.5")) +``` diff --git a/pyproject.toml b/pyproject.toml index 9906c851..171e3ee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,12 @@ license.file = "LICENSE" readme = "README.md" requires-python = ">=3.11" + +[project.optional-dependencies] +server = ["pyserial", "zhinst-core"] [dependency-groups] dev = [ + "sm_bluesky[server]", "copier", "myst-parser", "pre-commit", diff --git a/src/sm_bluesky/beamlines/p99/plans/__init__.py b/src/sm_bluesky/beamlines/p99/plans/__init__.py index 1b48e674..18428026 100644 --- a/src/sm_bluesky/beamlines/p99/plans/__init__.py +++ b/src/sm_bluesky/beamlines/p99/plans/__init__.py @@ -1,5 +1,5 @@ -from sm_bluesky.common.helper import add_default_metadata from sm_bluesky.common.plans import grid_fast_scan, grid_step_scan +from sm_bluesky.common.utils import add_default_metadata P99_DEFAULT_METADATA = { "energy": {"value": 1.8, "unit": "eV"}, diff --git a/src/sm_bluesky/common/helper/__init__.py b/src/sm_bluesky/common/helper/__init__.py deleted file mode 100644 index 1c80c622..00000000 --- a/src/sm_bluesky/common/helper/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .add_meta import add_default_metadata, add_extra_names_to_meta - -__all__ = ["add_default_metadata", "add_extra_names_to_meta"] diff --git a/src/sm_bluesky/common/plans/fast_scan.py b/src/sm_bluesky/common/plans/fast_scan.py index 40c5421a..8bb14f0a 100644 --- a/src/sm_bluesky/common/plans/fast_scan.py +++ b/src/sm_bluesky/common/plans/fast_scan.py @@ -12,8 +12,8 @@ from ophyd_async.core import FlyMotorInfo from ophyd_async.epics.motor import Motor -from sm_bluesky.common.helper import add_extra_names_to_meta from sm_bluesky.common.plan_stubs import check_within_limit +from sm_bluesky.common.utils import add_extra_names_to_meta from sm_bluesky.log import LOGGER diff --git a/src/sm_bluesky/common/server/__init__.py b/src/sm_bluesky/common/server/__init__.py index fe40a135..fc5b77ca 100644 --- a/src/sm_bluesky/common/server/__init__.py +++ b/src/sm_bluesky/common/server/__init__.py @@ -1,3 +1,4 @@ from .abstract_instrument_server import AbstractInstrumentServer +from .zurich_lockin_amplifier import HF2Server -__all__ = ["AbstractInstrumentServer"] +__all__ = ["AbstractInstrumentServer", "HF2Server"] diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py new file mode 100644 index 00000000..2878c4ef --- /dev/null +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -0,0 +1,234 @@ +from time import sleep +from typing import Literal + +import numpy as np +from zhinst.core import ScopeModule, ziDAQServer + +from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.common.utils import auto_type_cast +from sm_bluesky.log import LOGGER + + +class HF2Server(AbstractInstrumentServer): + """Python class to create a sever that connect to HF2 data server and listen for + data request from client.""" + + api_level: Literal[0, 1, 4, 5, 6] + + def __init__( + self, + host: str = "", + port: int = 7891, + hf2_ip: str = "172.23.110.84", + hf2_port: int = 8004, + api_level: Literal[0, 1, 4, 5, 6] = 6, + device_id: str = "dev4206", + ): + super().__init__(host, port) + self.hf2_ip = hf2_ip + self.hf2_port = hf2_port + self.api_level = api_level + self.device_id = device_id + self._minimum_scope_wait = 0.1 + self._device: ziDAQServer | None = None + self._scope: ScopeModule | None = None + self._scope_frequency: float | None = None + # Register HF2 specific commands + self._command_registry.update( + { + b"getData": self._get_combined_data, + b"autoVoltageInRange": self._auto_voltage_range, + b"setTimeConstant": self._set_time_constant, + b"setDataRate": self._set_data_rate, + b"setCurrentInRange": self._set_current_range, + b"autoCurrentInRange": self._auto_current_range, + b"setRefFreq": self._set_ref_freq, + b"setRefV": self._set_ref_vpk, + b"setRefVoff": self._set_ref_voff, + b"setsRefOutSwitch": self._set_ref_output, + b"setsRefHarm": self._set_ref_harmonic, + b"setupScope": self._setup_scope_cmd, + } + ) + + @property + def device(self) -> ziDAQServer: + if self._device is None: + raise ConnectionError("Lockin amplifier not connected") + return self._device + + @device.setter + def device(self, value: ziDAQServer | None): + self._device = value + + @property + def scope(self) -> ScopeModule: + if self._scope is None: + raise ConnectionError( + "Scope module not initialized. Run setupScope before using scope." + ) + return self._scope + + @scope.setter + def scope(self, value: ScopeModule | None): + self._scope = value + + # --- Example Method --- + + def connect_hardware(self) -> bool: + """Connect to Zurich Instruments HF2 Data Server.""" + try: + self.device = ziDAQServer(self.hf2_ip, self.hf2_port, self.api_level) + self._setup_scope() + LOGGER.info(f"HF2 Data server connected at {self.hf2_ip}") + return True + except Exception as e: + self._error_helper("HF2 Connection failed", e) + return False + + def disconnect_hardware(self) -> None: + """Disconnect from HF2 and cleanup modules.""" + try: + self.device.disconnect() + LOGGER.info("HF2 disconnected") + except Exception as e: + self._error_helper("Error during HF2 disconnect", e) + finally: + self.device = None + + # --- Hardware Logic Methods --- + def _setup_scope(self, freq: float = 5.0, length: int = 4096, channel: int = 0): + self.scope = self.device.scopeModule() + self._scope_frequency = 5.0 + self.device.set(f"/{self.device_id}/scopes/0/time", freq) + self.device.set(f"/{self.device_id}/scopes/0/length", length) + self.device.set(f"/{self.device_id}/scopes/0/channels/0/inputselect", channel) + self.device.set(f"/{self.device_id}/scopes/0/enable", 0) + + def _get_single_scope_shot(self) -> float: + """Returns the mean value of a single scope shot.""" + if self._scope_frequency: + self.device.set(f"/{self.device_id}/scopes/0/enable", 0) + self.scope.set("scopeModule/mode", 1) + self.scope.subscribe(f"/{self.device_id}/scopes/0/wave/") + self.scope.execute() + self.device.setInt(f"/{self.device_id}/scopes/0/single", 1) + self.device.setInt(f"/{self.device_id}/scopes/0/enable", 1) + self.device.sync() + sleep(1.0 / self._scope_frequency + self._minimum_scope_wait) + self.scope.finish() + result = self.scope.read(True) + static_mean = result[f"/{self.device_id}/scopes/0/wave"][0][0]["wave"][ + 0 + ].mean() + self.scope.unsubscribe("*") + self.device.set(f"/{self.device_id}/scopes/0/enable", 0) + return float(static_mean) + else: + raise ValueError( + "Scope frequency not set, use 'setupScope' before taking data." + ) + + def _get_lockin_data(self, duration: float) -> tuple[float, float, float, float]: + """Averages demodulator data over a specific duration.""" + + path = f"/{self.device_id}/demods/0/sample" + self.device.subscribe(path) + try: + poll_results = self.device.poll( + recording_time_s=duration, timeout_ms=500, flat=True + ) + if path in poll_results: + samples = poll_results[path] + avg_x = float(np.mean(samples["x"])) + avg_y = float(np.mean(samples["y"])) + else: + LOGGER.warning( + f"Poll returned no data for {path}, falling back to getSample" + ) + sample = self.device.getSample(path) + avg_x, avg_y = float(sample["x"]), float(sample["y"]) + finally: + self.device.unsubscribe(path) + + r = float(np.abs(avg_x + 1j * avg_y)) + theta = float(np.rad2deg(np.arctan2(avg_y, avg_x))) + return avg_x, avg_y, r, theta + + def _set_node(self, path: str, value: float | int, response_msg: bytes): + if isinstance(value, int): + self.device.setInt(f"/{self.device_id}/{path}", value) + self._send_response(response_msg + b": %i" % value) + else: + self.device.setDouble(f"/{self.device_id}/{path}", value) + self._send_response(response_msg + b": %f" % value) + + # --- Command Handlers --- + @auto_type_cast + def _get_combined_data(self, duration: float = 0.1) -> None: + x, y, r, theta = self._get_lockin_data(duration) + static = self._get_single_scope_shot() + response = f"{x:e}, {y:e}, {theta:f}, {static:e}, {r:e}" + self._send_response(response.encode()) + + @auto_type_cast + def _setup_scope_cmd(self, freq: float = 5.0, length: int = 4096, channel: int = 0): + self._setup_scope(freq, length, channel) + self._send_response(b"Scope configured") + + @auto_type_cast + def _set_current_range(self, value: float): + # current range is in multiple of 10 between 1e-9 to 1e-2 + exponent = int(np.floor(np.log10(value))) + + self._set_node( + path="currins/0/range", + value=10.0**exponent, + response_msg=b"Current range set", + ) + + @auto_type_cast + def _set_ref_output(self, value: int): + self._set_node( + path="sigouts/0/enables/1", value=value, response_msg=b"Output set to" + ) + + def _auto_voltage_range(self): + self._set_node( + path="sigins/0/autorange", value=1, response_msg=b"Auto voltage triggered" + ) + + def _auto_current_range(self): + self._set_node( + path="currins/0/autorange", value=1, response_msg=b"Auto current triggered" + ) + + @auto_type_cast + def _set_time_constant(self, val: float): + self._set_node( + path="demods/0/timeconstant", value=val, response_msg=b"Time constant set" + ) + + @auto_type_cast + def _set_ref_freq(self, val: float): + self._set_node(path="oscs/0/freq", value=val, response_msg=b"Frequency set") + + @auto_type_cast + def _set_data_rate(self, val: float): + self._set_node(path="demods/0/rate", value=val, response_msg=b"Data rate set") + + @auto_type_cast + def _set_ref_vpk(self, val: float): + self._set_node( + path="sigouts/0/amplitudes/1", value=val, response_msg=b"Ref Vpk set" + ) + + @auto_type_cast + def _set_ref_voff(self, val: float): + self._set_node(path="sigouts/0/offset", value=val, response_msg=b"Ref Voff set") + + @auto_type_cast + def _set_ref_harmonic(self, val: float): + self._set_node( + path="demods/1/harmonic", value=val, response_msg=b"Harmonic set" + ) diff --git a/src/sm_bluesky/common/utils/__init__.py b/src/sm_bluesky/common/utils/__init__.py new file mode 100644 index 00000000..96d11711 --- /dev/null +++ b/src/sm_bluesky/common/utils/__init__.py @@ -0,0 +1,3 @@ +from .decorators import add_default_metadata, add_extra_names_to_meta, auto_type_cast + +__all__ = ["add_default_metadata", "add_extra_names_to_meta", "auto_type_cast"] diff --git a/src/sm_bluesky/common/helper/add_meta.py b/src/sm_bluesky/common/utils/decorators.py similarity index 50% rename from src/sm_bluesky/common/helper/add_meta.py rename to src/sm_bluesky/common/utils/decorators.py index 1bffcf8a..f29ec75c 100644 --- a/src/sm_bluesky/common/helper/add_meta.py +++ b/src/sm_bluesky/common/utils/decorators.py @@ -1,6 +1,7 @@ +import inspect from collections.abc import Callable from functools import wraps -from typing import Any, TypeVar, cast +from typing import Any, TypeVar, cast, get_type_hints from bluesky.utils import MsgGenerator @@ -46,3 +47,37 @@ def add_extra_names_to_meta( return md md[key] = names return md + + +def auto_type_cast(func: Callable) -> Callable: + """ + Casts positional byte arguments to hinted types. + Skips 'self' and handles empty strings gracefully. + """ + + @wraps(func) + def wrapper(*args) -> Callable: + sig = inspect.signature(func) + hints = get_type_hints(func) + + bound_args = sig.bind(*args) + bound_args.apply_defaults() + + for name, value in bound_args.arguments.items(): + if isinstance(value, bytes) and name in hints: + target_type = hints[name] + try: + str_val = value.decode("utf-8").strip() + if not str_val: + default_val = sig.parameters[name].default + if default_val is not inspect.Parameter.empty: + bound_args.arguments[name] = default_val + continue + if target_type in (int, float, str): + bound_args.arguments[name] = target_type(str_val) + except (ValueError, UnicodeDecodeError) as e: + raise TypeError(f"Argument '{name}' casting failed: {e}") from e + + return func(*bound_args.args) + + return wrapper diff --git a/tests/common/server/test_zurich_lockin_amplifier.py b/tests/common/server/test_zurich_lockin_amplifier.py new file mode 100644 index 00000000..510a60be --- /dev/null +++ b/tests/common/server/test_zurich_lockin_amplifier.py @@ -0,0 +1,322 @@ +from unittest.mock import MagicMock, call, patch + +import numpy as np +import pytest +from zhinst.core import ziDAQServer + +from sm_bluesky.common.server import HF2Server + + +@pytest.fixture +def mock_daq(): + """Patches Serial and returns the class mock.""" + with patch( + "sm_bluesky.common.server.zurich_lockin_amplifier.ziDAQServer", spec=True + ) as mock_daq: + yield mock_daq + + +@pytest.fixture +def mock_server(mock_daq: ziDAQServer): + """Provides a fresh server instance with a mocked device for every test.""" + mock_server = HF2Server() + mock_server.device = mock_daq + return mock_server + + +def test_connect_hardware_success( + mock_server: HF2Server, caplog: pytest.LogCaptureFixture +): + mock_server._setup_scope = MagicMock() + mock_server.connect_hardware() + + mock_server._setup_scope.assert_called_once() + assert f"HF2 Data server connected at {mock_server.hf2_ip}" in caplog.text + + +def test_connect_hardware_failed( + mock_server: HF2Server, caplog: pytest.LogCaptureFixture, mock_daq: MagicMock +): + error_message = "Failed to Connect" + mock_daq.side_effect = Exception(error_message) + mock_server._send_error = MagicMock() + mock_server.connect_hardware() + mock_server._send_error.assert_called_once_with( + f"HF2 Connection failed: {error_message}" + ) + + +def test_disconnect_hardware_failed(mock_server: HF2Server): + with patch.object(mock_server, "_device") as mock_device: + error_message = "Failed to disconnect" + mock_device.disconnect.side_effect = Exception(error_message) + mock_server._send_error = MagicMock() + mock_server.disconnect_hardware() + mock_server._send_error.assert_called_once_with( + f"Error during HF2 disconnect: {error_message}" + ) + + +def test_disconnect_hardware_failed_no_device(mock_server: HF2Server): + mock_server.device = None + mock_server._send_error = MagicMock() + mock_server.disconnect_hardware() + mock_server._send_error.assert_called_once_with( + "Error during HF2 disconnect: Lockin amplifier not connected" + ) + + +def test_disconnect_hardware(mock_server: HF2Server): + with patch.object(mock_server, "_device") as mock_device: + mock_device.disconnect = MagicMock() + mock_server.disconnect_hardware() + mock_device.disconnect.assert_called_once() + assert mock_server._device is None + + +@pytest.mark.parametrize( + "args, expected", + [ + ([10], [10, 4096, 0, 0]), + ([10, 11], [10, 11, 0, 0]), + ([10, 11, 322], [10, 11, 322, 0]), + ], +) +def test_setup_scope_success(args: list[int], expected: list, mock_server: HF2Server): + cmd = ["time", "length", "channels/0/inputselect", "enable"] + with patch.object(mock_server, "_device") as mock_device: + mock_device.set = MagicMock() + mock_server._setup_scope(*args) + for i, arg in enumerate(mock_device.set.call_args_list): + assert arg.args == ( + f"/{mock_server.device_id}/scopes/0/{cmd[i]}", + expected[i], + ) + + +def test_setup_scope_failed_no_device(mock_server: HF2Server): + mock_server.device = None + mock_server._send_error = MagicMock() + with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): + mock_server._setup_scope() + + +@patch("sm_bluesky.common.server.zurich_lockin_amplifier.sleep") +def test_get_single_scope_shot_success(mock_sleep: MagicMock, mock_server: HF2Server): + + mock_server._scope_frequency = 1000 + mock_server._device = MagicMock() + mock_server._scope = MagicMock() + mock_wave = np.array([1.0, 2.0, 3.0, 4.0]) + mock_result = {"/dev4206/scopes/0/wave": [[{"wave": [mock_wave]}]]} + mock_server._scope.read.return_value = mock_result + result = mock_server._get_single_scope_shot() + assert result == 2.5 + + expected_device_calls = [ + call.set("/dev4206/scopes/0/enable", 0), + call.setInt("/dev4206/scopes/0/single", 1), + call.setInt("/dev4206/scopes/0/enable", 1), + call.sync(), + call.set("/dev4206/scopes/0/enable", 0), + ] + mock_server._device.assert_has_calls(expected_device_calls, any_order=False) + expected_scope_calls = [ + call.set("scopeModule/mode", 1), + call.subscribe("/dev4206/scopes/0/wave/"), + call.execute(), + call.finish(), + call.read(True), + call.unsubscribe("*"), + ] + mock_server._scope.assert_has_calls(expected_scope_calls, any_order=False) + assert 1.0 / 1000.0 + mock_server._minimum_scope_wait == pytest.approx( + mock_sleep.call_args.args[0], rel=0.01 + ) + + +def test_get_single_scope_shot_connection_error(mock_server: HF2Server): + """Verifies that it raises ConnectionError if components are missing.""" + mock_server._device = None + mock_server._scope_frequency = 1 + with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): + mock_server._get_single_scope_shot() + + +def test_get_single_scope_shot_scope_error(mock_server: HF2Server): + """Verifies that it raises ConnectionError if components are missing.""" + mock_server._scope = None + mock_server._scope_frequency = 1 + with pytest.raises( + ConnectionError, + match="Scope module not initialized. Run setupScope before using scope.", + ): + mock_server._get_single_scope_shot() + + +def test_get_single_scope_shot_frequncy_error(mock_server: HF2Server): + """Verifies that it raises ConnectionError if components are missing.""" + with pytest.raises( + ValueError, + match="Scope frequency not set, use 'setupScope' before taking data.", + ): + mock_server._get_single_scope_shot() + + +def test_get_lockin_data_averaging_polling_failed(mock_server: HF2Server): + mock_server._device = MagicMock() + mock_server._device.poll.return_value = {} + mock_server._device.getSample.return_value = {"x": 1.0, "y": 2.0} + + x, y, r, theta = mock_server._get_lockin_data(1.0) + + assert x == 1.0 + assert y == 2.0 + assert r == pytest.approx(2.236, rel=1e-4) + + assert theta == pytest.approx(63.434, rel=1e-4) + assert mock_server._device.getSample.call_count == 1 + + +def test_get_lockin_data_averaging_polling(mock_server: HF2Server): + path = f"/{mock_server.device_id}/demods/0/sample" + mock_server._device = MagicMock() + mock_server._device.poll.side_effect = [ + {path: {"x": [1, 3.0], "y": [2, 4]}}, + ] + duration = 1.0 + x, y, r, theta = mock_server._get_lockin_data(duration) + + assert x == 2.0 + assert y == 3.0 + assert r == pytest.approx(3.60555, rel=1e-4) + + assert theta == pytest.approx(56.3099, rel=1e-4) + mock_server._device.subscribe.assert_called_once_with(path) + mock_server._device.poll.assert_called_once_with( + recording_time_s=duration, timeout_ms=500, flat=True + ) + mock_server._device.unsubscribe.assert_called_once_with(path) + assert mock_server._device.getSample.call_count == 0 + + +def test_get_lockin_data_fail(mock_server: HF2Server): + mock_server.device = None + with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): + mock_server._get_lockin_data(0.1) + + +@pytest.mark.parametrize( + "method_name, val_bytes, expected_path, expected_val, expected_response", + [ + ( + "_set_time_constant", + b"0.01", + "/dev4206/demods/0/timeconstant", + 0.01, + b"Time constant set", + ), + ("_set_data_rate", b"400", "/dev4206/demods/0/rate", 400.0, b"Data rate set"), + ( + "_set_ref_vpk", + b"0.5", + "/dev4206/sigouts/0/amplitudes/1", + 0.5, + b"Ref Vpk set", + ), + ("_set_ref_voff", b"0.1", "/dev4206/sigouts/0/offset", 0.1, b"Ref Voff set"), + ( + "_set_ref_harmonic", + b"2.0", + "/dev4206/demods/1/harmonic", + 2.0, + b"Harmonic set", + ), + ("_set_ref_freq", b"20.5", "/dev4206/oscs/0/freq", 20.5, b"Frequency set"), + ( + "_set_current_range", + b"1e-4", + "/dev4206/currins/0/range", + 10.0**-4, + b"Current range set", + ), + ], +) +def test_commond_mapping_method_double( + mock_server: HF2Server, + method_name: str, + val_bytes: bytes, + expected_path: str, + expected_val: float, + expected_response: bytes, +): + method = getattr(mock_server, method_name) + mock_server._device = MagicMock() + mock_server._send_response = MagicMock() + method(val_bytes) + mock_server._device.setDouble.assert_called_once_with(expected_path, expected_val) + response = expected_response + b": %f" % expected_val + mock_server._send_response.assert_called_once_with(response) + + +@pytest.mark.parametrize( + "method_name, val_bytes, expected_path, expected_response", + [ + ( + "_auto_voltage_range", + [], + "/dev4206/sigins/0/autorange", + b"Auto voltage triggered", + ), + ( + "_auto_current_range", + [], + "/dev4206/currins/0/autorange", + b"Auto current triggered", + ), + ( + "_set_ref_output", + [b"1"], + "/dev4206/sigouts/0/enables/1", + b"Output set to", + ), + ], +) +def test_commond_mapping_method_int( + mock_server: HF2Server, + method_name: str, + val_bytes: bytes, + expected_path: str, + expected_response: bytes, +): + method = getattr(mock_server, method_name) + mock_server._send_response = MagicMock() + mock_server._device = MagicMock() + method(*val_bytes) + + mock_server._device.setInt.assert_called_once_with(expected_path, 1) + mock_server._send_response.assert_called_once_with(expected_response + b": 1") + + +def test_get_combined_data( + mock_server: HF2Server, +): + duration = b"0.2" + mock_server._get_lockin_data = MagicMock(return_value=(1, 2, 3, 4)) + mock_server._get_single_scope_shot = MagicMock(return_value=5) + mock_server._send_response = MagicMock() + mock_server._get_combined_data(duration) # type: ignore + mock_server._get_lockin_data.assert_called_once_with( + float(duration.decode("utf-8")) + ) + mock_server._get_single_scope_shot.assert_called_once() + response = f"{1:e}, {2:e}, {4:f}, {5:e}, {3:e}".encode() + mock_server._send_response.assert_called_once_with(response) + + +def test_setup_scope_cmd(mock_server: HF2Server): + mock_server._setup_scope = MagicMock() + mock_server._send_response = MagicMock() + mock_server._setup_scope_cmd() + assert mock_server._send_response(b"Scope configured") + mock_server._setup_scope.assert_called_once() diff --git a/tests/common/helper/test_helper.py b/tests/common/utils/test_decorators.py similarity index 68% rename from tests/common/helper/test_helper.py rename to tests/common/utils/test_decorators.py index 629913c9..7203e1b3 100644 --- a/tests/common/helper/test_helper.py +++ b/tests/common/utils/test_decorators.py @@ -5,11 +5,12 @@ from bluesky.plans import count from bluesky.run_engine import RunEngine -from sm_bluesky.common.helper.add_meta import ( +from sm_bluesky.common.sim_devices import SimStage +from sm_bluesky.common.utils.decorators import ( add_default_metadata, add_extra_names_to_meta, + auto_type_cast, ) -from sm_bluesky.common.sim_devices import SimStage DEFAULT_METADATA = { "energy": {"value": 1.8, "unit": "eV"}, @@ -97,3 +98,49 @@ def test_add_extra_names_to_meta_dictionary_fail_value_not_list() -> None: md = {"Bound": some_plan} with pytest.raises(TypeError): md = add_extra_names_to_meta(md=md, key="Bound", names=["James"]) + + +class TestCasting: + @auto_type_cast + def set_params( + self, + integer: int = 1, + double: float = 5.0, + string: str = "string", + ): + return integer, double, string + + +@pytest.fixture +def test_casting(): + return TestCasting() + + +def test_auto_type_cast_default(test_casting: TestCasting): + interger, number, string = test_casting.set_params() + + assert interger == 1 + assert number == 5 + assert string == "string" + assert isinstance(interger, int) + assert isinstance(number, float) + assert isinstance(string, str) + + +def test_cast_invalid(test_casting: TestCasting): + with pytest.raises(TypeError, match="Argument 'integer' casting failed"): + test_casting.set_params(b"not_an_int", b"1.0", b"test") # pyright: ignore[reportArgumentType] + + +@pytest.mark.parametrize( + "test_input, expected_result", + [ + ([b"5", b"6.5", b"hello"], (5, 6.5, "hello")), + ([b"10", b"0.0", b"world"], (10, 0.0, "world")), + ([b"1", b"1.1", b""], (1, 1.1, "string")), + ([b"1"], (1, 5, "string")), + ], +) +def test_auto_type_cast_multi(test_input, expected_result, test_casting: TestCasting): + result = test_casting.set_params(*test_input) + assert result == expected_result diff --git a/uv.lock b/uv.lock index e7cbf2d4..356b9e37 100644 --- a/uv.lock +++ b/uv.lock @@ -4725,6 +4725,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -5476,6 +5485,12 @@ dependencies = [ { name = "scanspec" }, ] +[package.optional-dependencies] +server = [ + { name = "pyserial" }, + { name = "zhinst-core" }, +] + [package.dev-dependencies] dev = [ { name = "blueapi" }, @@ -5495,6 +5510,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sm-bluesky", extra = ["server"] }, { name = "sphinx-autobuild" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -5507,8 +5523,11 @@ requires-dist = [ { name = "bluesky" }, { name = "dls-dodal", specifier = ">=2.2.0" }, { name = "ophyd-async", extras = ["sim"] }, + { name = "pyserial", marker = "extra == 'server'" }, { name = "scanspec" }, + { name = "zhinst-core", marker = "extra == 'server'" }, ] +provides-extras = ["server"] [package.metadata.requires-dev] dev = [ @@ -5528,6 +5547,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sm-bluesky", extras = ["server"] }, { name = "sphinx-autobuild" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -6649,6 +6669,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] +[[package]] +name = "zhinst-core" +version = "26.1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/a0/d23c1ba7240aece3fd5d00a5386f0220dd2320b0f4f30a9814d0cfa0842e/zhinst_core-26.1.2.4-cp311-cp311-macosx_10_11_x86_64.whl", hash = "sha256:f2b463104b65c3382a77b1ec6378e07ae01f39e3221850cf9df85732e2c6ff58", size = 10505824, upload-time = "2026-03-03T07:54:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f0/4bf521d5003fdf13e65ff7999084707cb047458f34bee0648215ca0d3564/zhinst_core-26.1.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a4f3bc0859e1fcbb905759d05a44d229338c0911a16e7f0ce8a8355c59bebfc1", size = 9688448, upload-time = "2026-03-03T07:54:18.197Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fe/9e313156bdb88b869a084c0c5139320b1657107e83e198510ac9a2e6c3f2/zhinst_core-26.1.2.4-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:5a193ad67b2e682bd51a04c190fdc77fd745fe3482e935af600c71124d57a2d8", size = 13261368, upload-time = "2026-03-03T07:54:22.021Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/af8a4aed81387833d982297e5390ab5db407bb5ea60d697aefe89c734bdd/zhinst_core-26.1.2.4-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:1e0d534cd72904b3d334a26951a9be60ab0948733718bef81292e74c18d4084c", size = 13143218, upload-time = "2026-03-03T07:54:26.13Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/1fae1093bff1d60f12c921e96ffa1000524b4cfff7815be2a78a2bb4100d/zhinst_core-26.1.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:ed0691a11828fbfd496807867cc099288e42b6da342f065c0cf6aa4f57a8f643", size = 9311186, upload-time = "2026-03-03T07:54:29.661Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f04710fd5526565c9d8595faf874ec7c3229e590c5734451fb9898efcbbf/zhinst_core-26.1.2.4-cp312-cp312-macosx_10_11_x86_64.whl", hash = "sha256:9c4db7a1f2b0f886f089e7e8798caf6bf16f26c6aad0df5bd18cc1d1c945c0ff", size = 10516764, upload-time = "2026-03-03T07:54:33.38Z" }, + { url = "https://files.pythonhosted.org/packages/c8/58/8796d49324f049c24824de38a16baac81d82cb9f19748f43521cd1128355/zhinst_core-26.1.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7e84b21075f4d262e14a77b95714f8dd130bc06df8d1be6891d22467491e8c13", size = 9692958, upload-time = "2026-03-03T07:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/75/8d/78aa1506f6f00df78c78a4dd8a3cd899497976720122292f064090f58bad/zhinst_core-26.1.2.4-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:f567345ec82b9d15daedfe72acd32b58a4617b8ad724e32b33d30937e16a4259", size = 13269276, upload-time = "2026-03-03T07:54:41.439Z" }, + { url = "https://files.pythonhosted.org/packages/6d/75/14b4a44b56e2bd2b0993ca7acce06231edf20a619a978ea51131f9427c60/zhinst_core-26.1.2.4-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:fc2d9064654e1f3cc5431455c9762008613011b048e5bd12665e83155e29941f", size = 13146601, upload-time = "2026-03-03T07:54:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/95d46c5c6f617e11119b2c3f50d2661024e319fb2fbc98d2b20067da168f/zhinst_core-26.1.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:fa1f3e0a2b7e95a47965de392d082366c4b70651218a1fc27c7e93b84231c52a", size = 9318142, upload-time = "2026-03-03T07:54:50.615Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/bcd42e05686e504c1900640f5b665a0b29a47c1c93c9c1bdde998e6ef6ac/zhinst_core-26.1.2.4-cp313-cp313-macosx_10_11_x86_64.whl", hash = "sha256:f806272ef68e8bdeae5c98e3b9c49966fcd8b12cc527376f01816c043cdf670d", size = 10516690, upload-time = "2026-03-03T07:54:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/32/3c/d64493b076a83b4ce744a18f0ef326bc869c2908c64ad1b6f28661299536/zhinst_core-26.1.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a663b98d1ec51e255dd445fb6a4cfd181dd7916e09feecbf0abce1bfe794e3", size = 9693055, upload-time = "2026-03-03T07:54:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/683e3a8a3995218f34dff9e3780e0b877400166f119ac9c7b2f9e556cd9a/zhinst_core-26.1.2.4-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:d8175284d358cc9ef476c1c27ddf47d32d2676332520b96f43b33ebfcf9a6f55", size = 13269376, upload-time = "2026-03-03T07:55:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/9c/69/d3e75953c52359a5f7c30f912768578945b3709cdecb5d26ec42e44b0bec/zhinst_core-26.1.2.4-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:866c317e2458de000b5feb2ebe30620c0277d4aa8de0bd3bf7f392446184ea11", size = 13145788, upload-time = "2026-03-03T07:55:06.052Z" }, + { url = "https://files.pythonhosted.org/packages/19/fd/3b91854ca91ce1654a89fc654cdd6e1e6d4ce224b550c82ac3b369e4c7f9/zhinst_core-26.1.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:6a6c94eaaf430fe65dcce9cc08d8c6cfea643fd455c6c03f3d28253c1f160e1e", size = 9317952, upload-time = "2026-03-03T07:55:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/e4a4f9774ad46505c7f8d0e4473458480d69373011e870f0759b6b9f7a9a/zhinst_core-26.1.2.4-cp314-cp314-macosx_10_11_x86_64.whl", hash = "sha256:dd465265e80f0fca57f69a61ee4471ac121076f5f6a7bc77888fd0975e7a96d1", size = 10512542, upload-time = "2026-03-03T07:55:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/83/f6fb061ee9891e638456f38a133f2e8ec760f39d96555a40870ed9acb33c/zhinst_core-26.1.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cee2adef326a417deb1454633e5355c654576d5e14393bfa7cdba657e86e46c8", size = 9689689, upload-time = "2026-03-03T07:55:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6e/e4e23191cb286e625caffc7578557f4d0bed6fad6a91a20cee690519b9c9/zhinst_core-26.1.2.4-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:28ea1af131eed70a19b27ad3148b6971c1714a94cea3ec1fa8de102c938e9586", size = 13268592, upload-time = "2026-03-03T07:55:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/4c526afb7d7cd5d6b90f2dcd44e1f84c071f5d7ed13a6adaa2e97a427a9d/zhinst_core-26.1.2.4-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:0622fc2a58cb799293dda6f0af81efb75e55088734bc3ab3e3f2d53a9813213e", size = 13148825, upload-time = "2026-03-03T07:55:23.294Z" }, + { url = "https://files.pythonhosted.org/packages/59/85/0b9b602936826db4c6317fc98a3bb7761386dff98568f95e39316656cc71/zhinst_core-26.1.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:38dc56b9245391a90af3cb908372c862868f6922b7a5654014074afe30a67d88", size = 9318497, upload-time = "2026-03-03T07:55:27.712Z" }, +] + [[package]] name = "zipp" version = "3.23.0"