From b33ac86734992b7a9641748b847d4f7c9fdb8e72 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Mon, 12 Feb 2024 09:59:27 +0100 Subject: [PATCH 01/37] apt_protocol with wip driver --- qmi/instruments/thorlabs/apt_packets.py | 160 +++++++++++ qmi/instruments/thorlabs/apt_protocol.py | 201 ++++++++++++++ qmi/instruments/thorlabs/mpc320.py | 333 +++++++++++++++++++++++ 3 files changed, 694 insertions(+) create mode 100644 qmi/instruments/thorlabs/apt_packets.py create mode 100644 qmi/instruments/thorlabs/apt_protocol.py create mode 100644 qmi/instruments/thorlabs/mpc320.py diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py new file mode 100644 index 00000000..444e7876 --- /dev/null +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -0,0 +1,160 @@ +from typing import List, Tuple +from qmi.instruments.thorlabs.apt_protocol import AptDataPacket, AptMessageHeaderWithParams, AptMessageId, apt_long, apt_dword, apt_char, apt_word, apt_byte + + +class HW_GET_INFO(AptDataPacket): + """ + Data packet structure for the HW_GET_INFO response. This packet is sent as a response to HW_GET_INFO. + + Fields: + serial_number: Serial number of device. + model_number: Model number of device. + type: Hardware type of device. + firmware_version: Firmware version of device. + hw_version: Hardware version of device. + mod_state: Modification state of device. + num_channels: Number of channels in device. + """ + MESSAGE_ID = AptMessageId.HW_GET_INFO.value + _fields_: List[Tuple[str, type]] = [ + ("serial_number", apt_long), + ("model_number", apt_char * 8), + ("type", apt_word), + ("firmware_version", apt_dword), + ("internal", apt_dword * 15), #this is for internal use, so we don't know what type it returns + ("hw_version", apt_word), + ("mod_state", apt_word), + ("num_channels", apt_word) + ] + +class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): + """ + Header structure for the MOD_GET_CHANENABLESTATE response. This header is sent as a response to MOD_REQ_CHANENABLESTATE. + + Fields: + message_id: ID of message. + chan_ident: Channel number. + enable_state: Indicate whether chanel is enabled or disabled. + dest: Destination of message. + source: Source of message. + """ + MESSAGE_ID = AptMessageId.MOD_GET_CHANENABLESTATE.value + _fields_: List[Tuple[str, type]] = [ + ("message_id", apt_word), + ("chan_ident", apt_byte), + ("enable_state", apt_byte), + ("dest", apt_byte), + ("source", apt_byte) + ] + +class MOT_GET_SET_POSCOUNTER(AptDataPacket): + """ + Data packet structure for the MOT_GET_POSCOUNTER command. This packet is sent as a response to MOT_REQ_POSCOUNTER. + It is also the data packet structure for the MOT_SET_POSCOUNTER. + + Fields: + chan_ident: Channel number. + position: Position in encoder counts. + """ + MESSAGE_ID = AptMessageId.MOT_GET_POSCOUNTER.value + _fields_: List[Tuple[str, type]] = [ + ("chan_ident", apt_word), + ("position", apt_long) + ] + +class MOT_MOVE_HOMED(AptMessageHeaderWithParams): + """ + Header structure for the MOT_MOVE_HOMED response. This header is sent as a response to MOT_MOVE_HOME once homing is complete. + + Fields: + message_id: ID of message. + chan_ident: Channel number. + param2: To be left as 0x00. + dest: Destination of message. + source: Source of message. + """ + MESSAGE_ID = AptMessageId.MOT_MOVE_HOMED.value + _fields_: List[Tuple[str, type]] = [ + ("message_id", apt_word), + ("chan_ident", apt_byte), + ("param2", apt_byte), + ("dest", apt_byte), + ("source", apt_byte) + ] + +class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): + """ + Header structure for the MOT_MOVE_COMPLETED response. This header is sent as a response to a relative or absolute move command + once the move has been completed. + + Fields: + message_id: ID of message. + chan_ident: Channel number. + param2: To be left as 0x00. + dest: Destination of message. + source: Source of message. + """ + MESSAGE_ID = AptMessageId.MOT_MOVE_COMPLETED.value + _fields_: List[Tuple[str, type]] = [ + ("message_id", apt_word), + ("chan_ident", apt_byte), + ("param2", apt_byte), + ("dest", apt_byte), + ("source", apt_byte) + ] + +class MOT_GET_USTATUSUPDATE(AptDataPacket): + """ + Data packet structure for a MOT_GET_USTATUSUPDATE command. + + Fields: + chan_ident: The channel being addressed. + position: The position in encoder counts. + velocity: Velocity in controller units. + motor_current: Motor current in mA. + status_bits: Status bits that provide various errors and indications. + """ + MESSAGE_ID = AptMessageId.MOT_GET_POSCOUNTER.value + _fields_: List[Tuple[str, type]] = [ + ("chan_ident", apt_word), + ("position", apt_long), + ("velocity", apt_word), + ("motor_current", apt_word), + ("status_bits", apt_dword) + ] + +class MOT_SET_EEPROMPARAMS(AptDataPacket): + """ + Data packet structure for a MOT_SET_EEPROMPARAMS command. + + Fields: + chan_ident: The channel being addressed. + msg_id: ID of message whose settings should be save. + """ + MESSAGE_ID = AptMessageId.MOT_SET_EEPROMPARAMS.value + _fields_: List[Tuple[str, type]] = [ + ("chan_ident", apt_word), + ("msg_id", apt_word) + ] + +class POL_GET_SET_PARAMS(AptDataPacket): + """" + Data packet structure for POL_SET_PARAMS command. It is also the data packet structure for the POL_SET_PARAMS. + + Fields: + not_used: This field is not used, but needs to be in the field structure to not break it. + velocity: Velocity in range 10% to 100% of 400 degrees/s. + home_position: Home position in encoder counts. + jog_step1: Size fo jog step to be performed on paddle 1. + jog_step2: Size fo jog step to be performed on paddle 2. + jog_step3: Size fo jog step to be performed on paddle 3. + """ + MESSAGE_ID = AptMessageId.POL_SET_PARAMS.value + _fields_: List[Tuple[str, type]] = [ + ("not_used", apt_word), + ("velocity", apt_word), + ("home_position", apt_word), + ("jog_step1", apt_word), + ("jog_step2", apt_word), + ("jog_step3", apt_word) + ] \ No newline at end of file diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py new file mode 100644 index 00000000..76127f9a --- /dev/null +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -0,0 +1,201 @@ + +""" +Module for the APT protocol used by Thorlabs. The documentation for the protocol can be found +here https://www.thorlabs.com/Software/Motion%20Control/APT_Communications_Protocol.pdf +""" +from ctypes import LittleEndianStructure, c_uint8, c_uint16, c_int16, c_uint32, c_int32, c_char, sizeof +from enum import Enum +from typing import List, Optional, Tuple, TypeVar + +from qmi.core.transport import QMI_Transport +from qmi.core.exceptions import QMI_InstrumentException + +APT_DATA_PACKET_TYPE = TypeVar("APT_DATA_PACKET_TYPE", bound='AptDataPacket') + +# APT format specifiers +apt_word = c_uint16 +apt_short = c_int16 +apt_dword = c_uint32 +apt_long = c_int32 +apt_char = c_char +apt_byte = c_uint8 # this format specifier is not defined in the APT protocol manual but is helpful for packets that are divided into single bytes + +class AptStatusBits(Enum): + """Status bits for a status update message.""" + P_MOT_SB_CWHARDLIMIT = 0x00000001 # clockwise hardware limit switch + P_MOT_SB_CCWHARDLIMIT = 0x00000002 # counter clockwise hardware limit switch + P_MOT_SB_CWSOFTLIMIT = 0x00000004 # clockwise software limit switch + P_MOT_SB_CCWSOFTLIMIT = 0x00000008 # counter clockwise software limit switch + P_MOT_SB_INMOTIONCW = 0X00000010 # in motion, clockwise direction + P_MOT_SB_INMOTIONCCW = 0x00000020 # in motion, counter clockwise direction + P_MOT_SB_JOGGINGCW = 0x00000040 # jogging in clockwise direction + P_MOT_SB_JOGGINGCCW = 0x00000080 # jogging in counter clockwise direction + P_MOT_SB_CONNECTED = 0x00000100 # motor recognised by controller + P_MOT_SB_HOMING = 0x00000200 # motor is homing + P_MOT_SB_HOMED = 0x00000400 # motor is homed + P_MOT_SB_INITIALISING = 0x00000800 # motor performing phase initialisation + P_MOT_SB_TRACKING = 0x00001000 # actual position is within the tracking window + P_MOT_SB_SETTLED = 0x00002000 # motor not moving and at target position + P_MOT_SB_POSITIONERERROR = 0x00004000 # actual position outside margin specified around trajectory position + P_MOT_SB_INSTRERROR = 0x00008000 # unable to execute command + P_MOT_SB_INTERLOCK = 0x00010000 # used in controllers where a seperate signal is used to enable the motor + P_MOT_SB_OVERTEMP = 0x00020000 # motor or motor power driver electronics reached maximum temperature + P_MOT_SB_BUSVOLTFAULT = 0x00040000 # low supply voltage + P_MOT_SB_COMMUTATIONERROR = 0x00080000 # problem with motor commutation. Can only be recovered with power cycle + P_MOT_SB_DIGIP1 = 0x00100000 # state of digital input 1 + P_MOT_SB_DIGIP2 = 0x00200000 # state of digital input 2 + P_MOT_SB_DIGIP4 = 0x00400000 # state of digital input 3 + P_MOT_SB_DIGIP8 = 0x00800000 # state of digital input 4 + P_MOT_SB_OVERLOAD = 0x01000000 # some form of motor overload + P_MOT_SB_ENCODERFAULT = 0x02000000 # encoder fault + P_MOT_SB_OVERCURRENT = 0x04000000 # motor exceeded continuous current limit + P_MOT_SB_BUSCURRENTFAULT = 0x08000000 # excessive current being drawn from motor power supply + P_MOT_SB_POWEROK = 0x10000000 # controller power supplies operating normally + P_MOT_SB_ACTIVE = 0x20000000 # controller executing motion commend + P_MOT_SB_ERROR = 0x40000000 # indicates an error condition + P_MOT_SB_ENABLED = 0x80000000 # motor output enabled, with controller maintaining position + +class AptMessageId(Enum): + """Possible message IDs for messages to the Thorlabs MPC320.""" + HW_REQ_INFO = 0x0005 + HW_GET_INFO = 0x0006 + MOD_IDENTIFY = 0X0223 + MOD_REQ_CHANENABLESTATE = 0x0211 + MOD_GET_CHANENABLESTATE = 0x0212 + HW_DISCONNECT = 0x0002 + HW_START_UPDATEMSGS = 0x0011 + HW_STOP_UPDATEMSGS = 0x0012 + RESTOREFACTORYSETTINGS = 0x0686 + MOT_SET_POSCOUNTER = 0x0410 + MOT_REQ_POSCOUNTER = 0x0411 + MOT_GET_POSCOUNTER = 0x0412 + MOT_MOVE_HOME = 0x0443 + MOT_MOVE_HOMED = 0x0444 + MOT_MOVE_COMPLETED = 0x0464 + MOT_SET_EEPROMPARAMS = 0x04B9 + MOT_REQ_USTATUSUPDATE = 0x0490 + MOT_GET_USTATUSUPDATE = 0x0491 + POL_SET_PARAMS = 0x0530 + POL_REQ_PARAMS = 0x0531 + POL_GET_PARAMS = 0x0532 + +class AptChannelState(Enum): + """Possible channel states""" + ENABLE = 0x01 + DISABLE = 0x02 + +class AptMessageHeaderWithParams(LittleEndianStructure): + """ + This is the version of the APT message header when no data packet follows a header. + """ + _pack_ = True + _fields_: List[Tuple[str, type]] = [ + ("message_id", c_uint16), + ("param1", c_uint8), + ("param2", c_uint8), + ("dest", c_uint8), + ("source", c_uint8) + ] + +class AptMessageHeaderForData(LittleEndianStructure): + """ + This is the version of the APT message header when a data packet follows a header. + """ + _pack_ = True + _fields_: List[Tuple[str, type]] = [ + ("message_id", c_uint16), + ("date_length", c_uint16), + ("dest", c_uint8), + ("source", c_uint8) + ] + +class AptDataPacket(LittleEndianStructure): + """ + Base class for APT data packet. + """ + MESSAGE_ID: int + _pack_ = True + +class AptProtocol: + """ + Implement the Thorlabs APT protocol primitives. + """ + HEADER_SIZE_BYTES = 6 + def __init__(self, + transport: QMI_Transport, + apt_device_address: int = 0x50, + host_address: int = 0x01, + default_timeout: Optional[float] = None + ): + """Initialize the Thorlabs APT protocol handler. + + Parameters: + transport: Instance of `QMI_Transport` to use for sending APT commands to the instrument. + apt_device_address: The address of the APT device. By default it is 0x50 which is a generic USB hardware unit. + host_address: The address of the host that sends and receives messages. By default it is 0x01 which is + a host controller such as a PC. + default_timeout: Optional default response timeout in seconds. + The default is to wait indefinitely until a response is received. + """ + self._transport = transport + self._timeout = default_timeout + self._apt_device_address = apt_device_address + self._host_address = host_address + + def write_param_command(self, message_id: int, param1: Optional[int] = None, param2: Optional[int] = None) -> None: + """ + Send an APT protocol command that is a header (i.e. 6 bytes) with params. + + Parameters: + message_id: ID of message to send. + param1: Optional parameter 1 to be sent. + param2: Optional parameter 2 to be sent. + """ + # Make the command. + msg = AptMessageHeaderWithParams(message_id, param1 or 0x00, param2 or 0x00, self._apt_device_address, self._host_address) + + # Send command. + self._transport.write(msg) + + def write_data_command(self, message_id: int, data: APT_DATA_PACKET_TYPE) -> None: + """ + Send and APT protocol command with data. + """ + # Get size of data packet. + data_length = sizeof(data) + + # Make the header. + msg = AptMessageHeaderWithParams(message_id, data_length, self._apt_device_address | 0x80, self._host_address) + + # Send the header and data packet. + self._transport.write(msg) + self._transport.write(data) + + + def ask(self, data_type: APT_DATA_PACKET_TYPE, timeout: Optional[float] = None) -> APT_DATA_PACKET_TYPE: + """ + Ask for a response. + + Parameters: + data_type: Data type of the data packet that follows the header. + timeout: Optional response timeout in seconds. + + Returns: + The requested data typed as the provided data type. + """ + + if timeout is None: + timeout = self._timeout + + # Read the header first. + resp = self._transport.read(nbytes=self.HEADER_SIZE_BYTES, timeout=timeout) + header = AptMessageHeaderForData.from_buffer_copy(resp) + data_length = header.date_length + + # Check that the received message ID is the ID that is expected. + if data_type.MESSAGE_ID != header.message_id: + raise QMI_InstrumentException(f"Expected message with ID {data_type.MESSAGE_ID}, but received {header.message_id}") + + # Read the data packet that follows the header. + data_bytes = self._transport.read(nbytes=data_length, timeout=timeout) + return data_type.from_buffer_copy(data_bytes) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py new file mode 100644 index 00000000..8f23ecab --- /dev/null +++ b/qmi/instruments/thorlabs/mpc320.py @@ -0,0 +1,333 @@ +"""Module for a Thorlabs MPC320 motorised fibre polarisation controller.""" +import logging +from qmi.core.context import QMI_Context +from qmi.core.exceptions import QMI_TimeoutException +from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification +from qmi.core.rpc import rpc_method +from qmi.core.transport import create_transport +from qmi.instruments.thorlabs.apt_packets import HW_GET_INFO, MOD_GET_CHANENABLESTATE, MOT_GET_SET_POSCOUNTER, MOT_GET_USTATUSUPDATE, MOT_MOVE_COMPLETED, MOT_MOVE_HOMED, MOT_SET_EEPROMPARAMS, POL_GET_SET_PARAMS +from qmi.instruments.thorlabs.apt_protocol import AptChannelState, AptMessageId, AptProtocol +# Global variable holding the logger for this module. +_logger = logging.getLogger(__name__) + +class Thorlabs_MPC320(QMI_Instrument): + """ + Driver for a Thorlabs MPC320 motorised fibre polarisation controller. + """ + DEFAULT_RESPONSE_TIMEOUT = 1.0 + + def __init__(self, + context: QMI_Context, + name: str, + transport: str + ) -> None: + """Initialize the instrument driver. + + Parameters: + name: Name for this instrument instance. + transport: QMI transport descriptor to connect to the instrument. + """ + super().__init__(context, name) + self._transport = create_transport(transport, default_attributes={"baudrate": 115200, "rtscts": True}) + self._apt_protocol = AptProtocol(self._transport, default_timeout=self.DEFAULT_RESPONSE_TIMEOUT) + self._power_unit_configured = False + + @rpc_method + def open(self) -> None: + _logger.info("[%s] Opening connection to instrument", self._name) + self._check_is_closed() + self._transport.open() + super().open() + + @rpc_method + def close(self) -> None: + _logger.info("[%s] Closing connection to instrument", self._name) + super().close() + self._transport.close() + + @rpc_method + def get_idn(self) -> QMI_InstrumentIdentification: + """ + Read instrument type and version and return QMI_InstrumentIdentification instance. + + Returns: + an instance of QMI_InstrumentIdentification. The version refers to the firmware version. + """ + _logger.info("[%s] Getting identification of instrument", self._name) + self._check_is_open() + # Send request message. + self._apt_protocol.write_param_command(AptMessageId.HW_REQ_INFO.value) + # Get response + resp = self._apt_protocol.ask(HW_GET_INFO) + return QMI_InstrumentIdentification("Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version) + + @rpc_method + def identify_channel(self, channel_number: int) -> None: + """ + Identify a channel by flashing the front panel LEDs. + + Parameters: + channel_number: The channel to be identified. + """ + _logger.info("[%s] Identify channel %d", self._name, channel_number) + # TODO: check and validate channel number for MPC320 + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, channel_number) + + def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> None: + """ + Toggle the state of the channel. + + Parameters: + channel_number: The channel to toggle. + state: The state to change the channel to. + """ + # TODO: check and validate channel number for MPC320 + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, channel_number, state.value) + + @rpc_method + def enable_channel(self, channel_number: int) -> None: + """ + Enable the channel. + + Parameters: + channel_number: The channel to enable. + """ + _logger.info("[%s] Enabling channel %d", self._name, channel_number) + self._toggle_channel_state(channel_number, AptChannelState.ENABLE) + + @rpc_method + def disable_channel(self, channel_number: int) -> None: + """ + Disable the channel. + + Parameters: + channel_number: The channel to disable. + """ + _logger.info("[%s] Disabling channel %d", self._name, channel_number) + self._toggle_channel_state(channel_number, AptChannelState.DISABLE) + + @rpc_method + def get_channel_state(self, channel_number: int) -> AptChannelState: + """ + Get the state of the specified channel. + + Parameters: + channel_number: The channel to check. + + Returns: + The state of the channel as an AptChannelState enum. + """ + _logger.info("[%s] Getting state of channel %d", self._name, channel_number) + self._check_is_open() + # Send request message. + self._apt_protocol.write_param_command(AptMessageId.MOD_REQ_CHANENABLESTATE.value, channel_number) + # Get response + resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) + return AptChannelState(resp.enable_state) + + @rpc_method + def disconnect_hardware(self) -> None: + """ + Disconnect hardware from USB bus. + """ + _logger.info("[%s] Disconnecting instrument from USB hub", self._name) + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.HW_DISCONNECT.value) + + @rpc_method + def start_auto_status_update(self) -> None: + """ + Start automatic status updates from device. + """ + _logger.info("[%s] Starting automatic status updates from instrument", self._name) + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.HW_START_UPDATEMSGS.value) + + @rpc_method + def stop_auto_status_update(self) -> None: + """ + Stop automatic status updates from device. + """ + _logger.info("[%s] Stopping automatic status updates from instrument", self._name) + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.HW_STOP_UPDATEMSGS.value) + + @rpc_method + def restore_factory_settings(self) -> None: + """ + Restore settings to the default values stored in the EEPROM. + """ + _logger.info("[%s] Restoring factory settings of instrument", self._name) + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.RESTOREFACTORYSETTINGS.value) + + @rpc_method + def set_position_counter(self, channel_number: int, position_counter: int) -> None: + """ + Set the live position count in the controller. + + Paramters: + channel_number: The channel to change the position counter for. + position_counter: The value of the position counter. + """ + _logger.info("[%s] Changing position counter of channel %d to %d", self._name, channel_number, position_counter) + self._check_is_open() + # Make data packet. + data_packet = MOT_GET_SET_POSCOUNTER(chan_ident=channel_number, position=position_counter) + # Send message. + self._apt_protocol.write_data_command(AptMessageId.MOT_SET_POSCOUNTER.value, data_packet) + + @rpc_method + def get_position_counter(self, channel_number: int) -> int: + """ + Get the value of the position counter for a channel. + + Paramters: + channel_number: The channel to query. + + Returns: + position counter for a channel. + """ + _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) + self._check_is_open() + # Send request message. + self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_POSCOUNTER.value, channel_number) + # Get response + resp = self._apt_protocol.ask(MOT_GET_SET_POSCOUNTER) + return resp.position + + @rpc_method + def home_channel(self, channel_number: int) -> None: + """ + Start the homing sequence for a given channel. + + Paramters: + channel_number: The channel to home. + """ + _logger.info("[%s] Homing channel %d", self._name, channel_number) + self._check_is_open() + # Send message. + self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_HOME.value) + + + @rpc_method + def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: + """ + Check if a given channel is homed. This command should only be run after the method `home_channel`. + Otherwise you will read bytes from other commands using this method. + + Paramters: + channel_number: The channel to check. + timeout: The time to wait for a response to the homing command. This is optional + and is set to a default value of DEFAULT_RESPONSE_TIMEOUT. + + Returns: + True if the device was homed and a response was received before the timeout else False. + """ + _logger.info("[%s] Check if channel %d is homed", self._name, channel_number) + self._check_is_open() + # Get response + try: + _ = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) + return True + except QMI_TimeoutException: + return False + + @rpc_method + def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: + """ + Check if a given channel has completed its move. This command should only be run after a relative or absolute move command. + Otherwise you will read bytes from other commands using this method. + + Paramters: + channel_number: The channel to check. + timeout: The time to wait for a response to the homing command. This is optional + and is set to a default value of DEFAULT_RESPONSE_TIMEOUT. + + Returns: + True if the move was completed and a response was received before the timeout else False. + """ + # TODO: add status data packet + _logger.info("[%s] Check if channel %d has completed its move", self._name, channel_number) + self._check_is_open() + # Get response + try: + _ = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) + return True + except QMI_TimeoutException: + return False + + @rpc_method + def save_parameter_settings(self, channel_number: int, message_id: int) -> None: + """ + Save parameter settings for a specific message id. These parameters could have been edited via the QMI driver + or the GUI provided by Thorlabs. + + Parameters: + channel_number: The channel to address. + message_id: ID of message whose parameters need to be saved. + """ + _logger.info("[%s] Saving parameters of message %d", self._name, message_id) + self._check_is_open() + # Make data packet. + data_packet = MOT_SET_EEPROMPARAMS(chan_ident=channel_number, msg_id=message_id) + # Send message. + self._apt_protocol.write_data_command(AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet) + + @rpc_method + def get_status_update(self, channel_number: int) -> MOT_GET_USTATUSUPDATE: + """ + Get the status update for a given channel. + + Parameters: + channel_number: The channel to query. + + Returns: + An instance of MOT_GET_USTATUSUPDATE. + """ + _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) + self._check_is_open() + # Send request message. + self._apt_protocol.write_param_command(AptMessageId.MOT_GET_USTATUSUPDATE.value, channel_number) + # Get response + return self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) + + @rpc_method + def set_polarisation_parameters(self, velocity: int, home_pos: int, jog_step1: int, jog_step2: int, jog_step3: int) -> None: + """ + Set the polarisation parameters. + + Parameters: + velocity: Velocity in range 10% to 100% of 400 degrees/s. + home_position: Home position in encoder counts. + jog_step1: Size fo jog step to be performed on paddle 1. + jog_step2: Size fo jog step to be performed on paddle 2. + jog_step3: Size fo jog step to be performed on paddle 3. + """ + # TODO: finish command + _logger.info("[%s] Setting polarisation parameters", self._name) + self._check_is_open() + # Make data packet. + data_packet = POL_GET_SET_PARAMS(velocity=velocity, home_position=home_pos, jog_step1=jog_step1, jog_step2=jog_step2, jog_step3=jog_step3) + # Send message. + self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) + + @rpc_method + def get_polarisation_parameters(self) -> MOT_GET_SET_POSCOUNTER: + """ + Get the polarisation parameters. + """ + _logger.info("[%s] Getting polarisation parameters", self._name) + self._check_is_open() + # Send request message. + self._apt_protocol.write_param_command(AptMessageId.POL_REQ_PARAMS.value) + # Get response + return self._apt_protocol.ask(MOT_GET_SET_POSCOUNTER) \ No newline at end of file From 1b69756b631b82cd31d514596737dbe7958a402a Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Thu, 22 Feb 2024 12:52:36 +0100 Subject: [PATCH 02/37] typo --- qmi/instruments/thorlabs/apt_packets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index 444e7876..6df0829e 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -114,7 +114,7 @@ class MOT_GET_USTATUSUPDATE(AptDataPacket): motor_current: Motor current in mA. status_bits: Status bits that provide various errors and indications. """ - MESSAGE_ID = AptMessageId.MOT_GET_POSCOUNTER.value + MESSAGE_ID = AptMessageId.MOT_GET_USTATUSUPDATE.value _fields_: List[Tuple[str, type]] = [ ("chan_ident", apt_word), ("position", apt_long), From 8602142e2681d142202e0257708fbd9e0d85eb20 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 15 Mar 2024 14:18:09 +0100 Subject: [PATCH 03/37] removed unsupported methods --- qmi/instruments/thorlabs/apt_packets.py | 67 +++++----- qmi/instruments/thorlabs/apt_protocol.py | 149 +++++++++++++---------- qmi/instruments/thorlabs/mpc320.py | 126 +++++++++---------- 3 files changed, 176 insertions(+), 166 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index 6df0829e..a27375f6 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -1,5 +1,16 @@ +"""Module containing the packets for the APT protocol.""" + from typing import List, Tuple -from qmi.instruments.thorlabs.apt_protocol import AptDataPacket, AptMessageHeaderWithParams, AptMessageId, apt_long, apt_dword, apt_char, apt_word, apt_byte +from qmi.instruments.thorlabs.apt_protocol import ( + AptDataPacket, + AptMessageHeaderWithParams, + AptMessageId, + apt_long, + apt_dword, + apt_char, + apt_word, + apt_byte, +) class HW_GET_INFO(AptDataPacket): @@ -15,18 +26,23 @@ class HW_GET_INFO(AptDataPacket): mod_state: Modification state of device. num_channels: Number of channels in device. """ + MESSAGE_ID = AptMessageId.HW_GET_INFO.value _fields_: List[Tuple[str, type]] = [ ("serial_number", apt_long), ("model_number", apt_char * 8), ("type", apt_word), ("firmware_version", apt_dword), - ("internal", apt_dword * 15), #this is for internal use, so we don't know what type it returns + ( + "internal", + apt_dword * 15, + ), # this is for internal use, so we don't know what type it returns ("hw_version", apt_word), ("mod_state", apt_word), - ("num_channels", apt_word) + ("num_channels", apt_word), ] + class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): """ Header structure for the MOD_GET_CHANENABLESTATE response. This header is sent as a response to MOD_REQ_CHANENABLESTATE. @@ -38,29 +54,16 @@ class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): dest: Destination of message. source: Source of message. """ + MESSAGE_ID = AptMessageId.MOD_GET_CHANENABLESTATE.value _fields_: List[Tuple[str, type]] = [ ("message_id", apt_word), ("chan_ident", apt_byte), ("enable_state", apt_byte), ("dest", apt_byte), - ("source", apt_byte) + ("source", apt_byte), ] -class MOT_GET_SET_POSCOUNTER(AptDataPacket): - """ - Data packet structure for the MOT_GET_POSCOUNTER command. This packet is sent as a response to MOT_REQ_POSCOUNTER. - It is also the data packet structure for the MOT_SET_POSCOUNTER. - - Fields: - chan_ident: Channel number. - position: Position in encoder counts. - """ - MESSAGE_ID = AptMessageId.MOT_GET_POSCOUNTER.value - _fields_: List[Tuple[str, type]] = [ - ("chan_ident", apt_word), - ("position", apt_long) - ] class MOT_MOVE_HOMED(AptMessageHeaderWithParams): """ @@ -73,15 +76,17 @@ class MOT_MOVE_HOMED(AptMessageHeaderWithParams): dest: Destination of message. source: Source of message. """ + MESSAGE_ID = AptMessageId.MOT_MOVE_HOMED.value _fields_: List[Tuple[str, type]] = [ ("message_id", apt_word), ("chan_ident", apt_byte), ("param2", apt_byte), ("dest", apt_byte), - ("source", apt_byte) + ("source", apt_byte), ] + class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): """ Header structure for the MOT_MOVE_COMPLETED response. This header is sent as a response to a relative or absolute move command @@ -94,15 +99,17 @@ class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): dest: Destination of message. source: Source of message. """ + MESSAGE_ID = AptMessageId.MOT_MOVE_COMPLETED.value _fields_: List[Tuple[str, type]] = [ ("message_id", apt_word), ("chan_ident", apt_byte), ("param2", apt_byte), ("dest", apt_byte), - ("source", apt_byte) + ("source", apt_byte), ] + class MOT_GET_USTATUSUPDATE(AptDataPacket): """ Data packet structure for a MOT_GET_USTATUSUPDATE command. @@ -114,15 +121,17 @@ class MOT_GET_USTATUSUPDATE(AptDataPacket): motor_current: Motor current in mA. status_bits: Status bits that provide various errors and indications. """ - MESSAGE_ID = AptMessageId.MOT_GET_USTATUSUPDATE.value + + MESSAGE_ID = AptMessageId.MOT_REQ_USTATUSUPDATE.value _fields_: List[Tuple[str, type]] = [ ("chan_ident", apt_word), ("position", apt_long), ("velocity", apt_word), ("motor_current", apt_word), - ("status_bits", apt_dword) + ("status_bits", apt_dword), ] + class MOT_SET_EEPROMPARAMS(AptDataPacket): """ Data packet structure for a MOT_SET_EEPROMPARAMS command. @@ -131,14 +140,13 @@ class MOT_SET_EEPROMPARAMS(AptDataPacket): chan_ident: The channel being addressed. msg_id: ID of message whose settings should be save. """ + MESSAGE_ID = AptMessageId.MOT_SET_EEPROMPARAMS.value - _fields_: List[Tuple[str, type]] = [ - ("chan_ident", apt_word), - ("msg_id", apt_word) - ] + _fields_: List[Tuple[str, type]] = [("chan_ident", apt_word), ("msg_id", apt_word)] + class POL_GET_SET_PARAMS(AptDataPacket): - """" + """ " Data packet structure for POL_SET_PARAMS command. It is also the data packet structure for the POL_SET_PARAMS. Fields: @@ -149,6 +157,7 @@ class POL_GET_SET_PARAMS(AptDataPacket): jog_step2: Size fo jog step to be performed on paddle 2. jog_step3: Size fo jog step to be performed on paddle 3. """ + MESSAGE_ID = AptMessageId.POL_SET_PARAMS.value _fields_: List[Tuple[str, type]] = [ ("not_used", apt_word), @@ -156,5 +165,5 @@ class POL_GET_SET_PARAMS(AptDataPacket): ("home_position", apt_word), ("jog_step1", apt_word), ("jog_step2", apt_word), - ("jog_step3", apt_word) - ] \ No newline at end of file + ("jog_step3", apt_word), + ] diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index 76127f9a..e45cf81d 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -1,8 +1,8 @@ - """ Module for the APT protocol used by Thorlabs. The documentation for the protocol can be found here https://www.thorlabs.com/Software/Motion%20Control/APT_Communications_Protocol.pdf """ + from ctypes import LittleEndianStructure, c_uint8, c_uint16, c_int16, c_uint32, c_int32, c_char, sizeof from enum import Enum from typing import List, Optional, Tuple, TypeVar @@ -10,7 +10,7 @@ from qmi.core.transport import QMI_Transport from qmi.core.exceptions import QMI_InstrumentException -APT_DATA_PACKET_TYPE = TypeVar("APT_DATA_PACKET_TYPE", bound='AptDataPacket') +APT_DATA_PACKET_TYPE = TypeVar("APT_DATA_PACKET_TYPE", bound="AptDataPacket") # APT format specifiers apt_word = c_uint16 @@ -18,57 +18,58 @@ apt_dword = c_uint32 apt_long = c_int32 apt_char = c_char -apt_byte = c_uint8 # this format specifier is not defined in the APT protocol manual but is helpful for packets that are divided into single bytes +apt_byte = c_uint8 # this format specifier is not defined in the APT protocol manual but is helpful for packets that are divided into single bytes + class AptStatusBits(Enum): """Status bits for a status update message.""" - P_MOT_SB_CWHARDLIMIT = 0x00000001 # clockwise hardware limit switch - P_MOT_SB_CCWHARDLIMIT = 0x00000002 # counter clockwise hardware limit switch - P_MOT_SB_CWSOFTLIMIT = 0x00000004 # clockwise software limit switch - P_MOT_SB_CCWSOFTLIMIT = 0x00000008 # counter clockwise software limit switch - P_MOT_SB_INMOTIONCW = 0X00000010 # in motion, clockwise direction - P_MOT_SB_INMOTIONCCW = 0x00000020 # in motion, counter clockwise direction - P_MOT_SB_JOGGINGCW = 0x00000040 # jogging in clockwise direction - P_MOT_SB_JOGGINGCCW = 0x00000080 # jogging in counter clockwise direction - P_MOT_SB_CONNECTED = 0x00000100 # motor recognised by controller - P_MOT_SB_HOMING = 0x00000200 # motor is homing - P_MOT_SB_HOMED = 0x00000400 # motor is homed - P_MOT_SB_INITIALISING = 0x00000800 # motor performing phase initialisation - P_MOT_SB_TRACKING = 0x00001000 # actual position is within the tracking window - P_MOT_SB_SETTLED = 0x00002000 # motor not moving and at target position - P_MOT_SB_POSITIONERERROR = 0x00004000 # actual position outside margin specified around trajectory position - P_MOT_SB_INSTRERROR = 0x00008000 # unable to execute command - P_MOT_SB_INTERLOCK = 0x00010000 # used in controllers where a seperate signal is used to enable the motor - P_MOT_SB_OVERTEMP = 0x00020000 # motor or motor power driver electronics reached maximum temperature - P_MOT_SB_BUSVOLTFAULT = 0x00040000 # low supply voltage + + P_MOT_SB_CWHARDLIMIT = 0x00000001 # clockwise hardware limit switch + P_MOT_SB_CCWHARDLIMIT = 0x00000002 # counter clockwise hardware limit switch + P_MOT_SB_CWSOFTLIMIT = 0x00000004 # clockwise software limit switch + P_MOT_SB_CCWSOFTLIMIT = 0x00000008 # counter clockwise software limit switch + P_MOT_SB_INMOTIONCW = 0x00000010 # in motion, clockwise direction + P_MOT_SB_INMOTIONCCW = 0x00000020 # in motion, counter clockwise direction + P_MOT_SB_JOGGINGCW = 0x00000040 # jogging in clockwise direction + P_MOT_SB_JOGGINGCCW = 0x00000080 # jogging in counter clockwise direction + P_MOT_SB_CONNECTED = 0x00000100 # motor recognised by controller + P_MOT_SB_HOMING = 0x00000200 # motor is homing + P_MOT_SB_HOMED = 0x00000400 # motor is homed + P_MOT_SB_INITIALISING = 0x00000800 # motor performing phase initialisation + P_MOT_SB_TRACKING = 0x00001000 # actual position is within the tracking window + P_MOT_SB_SETTLED = 0x00002000 # motor not moving and at target position + P_MOT_SB_POSITIONERERROR = 0x00004000 # actual position outside margin specified around trajectory position + P_MOT_SB_INSTRERROR = 0x00008000 # unable to execute command + P_MOT_SB_INTERLOCK = 0x00010000 # used in controllers where a seperate signal is used to enable the motor + P_MOT_SB_OVERTEMP = 0x00020000 # motor or motor power driver electronics reached maximum temperature + P_MOT_SB_BUSVOLTFAULT = 0x00040000 # low supply voltage P_MOT_SB_COMMUTATIONERROR = 0x00080000 # problem with motor commutation. Can only be recovered with power cycle - P_MOT_SB_DIGIP1 = 0x00100000 # state of digital input 1 - P_MOT_SB_DIGIP2 = 0x00200000 # state of digital input 2 - P_MOT_SB_DIGIP4 = 0x00400000 # state of digital input 3 - P_MOT_SB_DIGIP8 = 0x00800000 # state of digital input 4 - P_MOT_SB_OVERLOAD = 0x01000000 # some form of motor overload - P_MOT_SB_ENCODERFAULT = 0x02000000 # encoder fault - P_MOT_SB_OVERCURRENT = 0x04000000 # motor exceeded continuous current limit - P_MOT_SB_BUSCURRENTFAULT = 0x08000000 # excessive current being drawn from motor power supply - P_MOT_SB_POWEROK = 0x10000000 # controller power supplies operating normally - P_MOT_SB_ACTIVE = 0x20000000 # controller executing motion commend - P_MOT_SB_ERROR = 0x40000000 # indicates an error condition - P_MOT_SB_ENABLED = 0x80000000 # motor output enabled, with controller maintaining position + P_MOT_SB_DIGIP1 = 0x00100000 # state of digital input 1 + P_MOT_SB_DIGIP2 = 0x00200000 # state of digital input 2 + P_MOT_SB_DIGIP4 = 0x00400000 # state of digital input 3 + P_MOT_SB_DIGIP8 = 0x00800000 # state of digital input 4 + P_MOT_SB_OVERLOAD = 0x01000000 # some form of motor overload + P_MOT_SB_ENCODERFAULT = 0x02000000 # encoder fault + P_MOT_SB_OVERCURRENT = 0x04000000 # motor exceeded continuous current limit + P_MOT_SB_BUSCURRENTFAULT = 0x08000000 # excessive current being drawn from motor power supply + P_MOT_SB_POWEROK = 0x10000000 # controller power supplies operating normally + P_MOT_SB_ACTIVE = 0x20000000 # controller executing motion commend + P_MOT_SB_ERROR = 0x40000000 # indicates an error condition + P_MOT_SB_ENABLED = 0x80000000 # motor output enabled, with controller maintaining position + class AptMessageId(Enum): - """Possible message IDs for messages to the Thorlabs MPC320.""" + """Message IDs for devices using the APT protocol.""" + HW_REQ_INFO = 0x0005 HW_GET_INFO = 0x0006 - MOD_IDENTIFY = 0X0223 + MOD_IDENTIFY = 0x0223 + MOD_SET_CHANENABLESTATE = 0x0210 MOD_REQ_CHANENABLESTATE = 0x0211 MOD_GET_CHANENABLESTATE = 0x0212 HW_DISCONNECT = 0x0002 HW_START_UPDATEMSGS = 0x0011 HW_STOP_UPDATEMSGS = 0x0012 - RESTOREFACTORYSETTINGS = 0x0686 - MOT_SET_POSCOUNTER = 0x0410 - MOT_REQ_POSCOUNTER = 0x0411 - MOT_GET_POSCOUNTER = 0x0412 MOT_MOVE_HOME = 0x0443 MOT_MOVE_HOMED = 0x0444 MOT_MOVE_COMPLETED = 0x0464 @@ -79,61 +80,74 @@ class AptMessageId(Enum): POL_REQ_PARAMS = 0x0531 POL_GET_PARAMS = 0x0532 + class AptChannelState(Enum): """Possible channel states""" + ENABLE = 0x01 DISABLE = 0x02 - + + class AptMessageHeaderWithParams(LittleEndianStructure): - """ - This is the version of the APT message header when no data packet follows a header. - """ - _pack_ = True - _fields_: List[Tuple[str, type]] = [ - ("message_id", c_uint16), - ("param1", c_uint8), - ("param2", c_uint8), - ("dest", c_uint8), - ("source", c_uint8) - ] + """ + This is the version of the APT message header when no data packet follows a header. + """ + + _pack_ = True + _fields_: List[Tuple[str, type]] = [ + ("message_id", c_uint16), + ("param1", c_uint8), + ("param2", c_uint8), + ("dest", c_uint8), + ("source", c_uint8), + ] + class AptMessageHeaderForData(LittleEndianStructure): """ This is the version of the APT message header when a data packet follows a header. """ + _pack_ = True _fields_: List[Tuple[str, type]] = [ ("message_id", c_uint16), ("date_length", c_uint16), ("dest", c_uint8), - ("source", c_uint8) + ("source", c_uint8), ] + class AptDataPacket(LittleEndianStructure): """ Base class for APT data packet. """ + MESSAGE_ID: int _pack_ = True + class AptProtocol: """ Implement the Thorlabs APT protocol primitives. """ + HEADER_SIZE_BYTES = 6 - def __init__(self, - transport: QMI_Transport, - apt_device_address: int = 0x50, - host_address: int = 0x01, - default_timeout: Optional[float] = None - ): + + def __init__( + self, + transport: QMI_Transport, + apt_device_address: int = 0x50, + host_address: int = 0x01, + default_timeout: Optional[float] = None, + ): """Initialize the Thorlabs APT protocol handler. Parameters: transport: Instance of `QMI_Transport` to use for sending APT commands to the instrument. - apt_device_address: The address of the APT device. By default it is 0x50 which is a generic USB hardware unit. - host_address: The address of the host that sends and receives messages. By default it is 0x01 which is - a host controller such as a PC. + apt_device_address: The address of the APT device. By default it is 0x50 which is a generic USB hardware + unit. + host_address: The address of the host that sends and receives messages. By default it is 0x01 which + is a host controller such as a PC. default_timeout: Optional default response timeout in seconds. The default is to wait indefinitely until a response is received. """ @@ -152,7 +166,9 @@ def write_param_command(self, message_id: int, param1: Optional[int] = None, par param2: Optional parameter 2 to be sent. """ # Make the command. - msg = AptMessageHeaderWithParams(message_id, param1 or 0x00, param2 or 0x00, self._apt_device_address, self._host_address) + msg = AptMessageHeaderWithParams( + message_id, param1 or 0x00, param2 or 0x00, self._apt_device_address, self._host_address + ) # Send command. self._transport.write(msg) @@ -163,7 +179,7 @@ def write_data_command(self, message_id: int, data: APT_DATA_PACKET_TYPE) -> Non """ # Get size of data packet. data_length = sizeof(data) - + # Make the header. msg = AptMessageHeaderWithParams(message_id, data_length, self._apt_device_address | 0x80, self._host_address) @@ -171,7 +187,6 @@ def write_data_command(self, message_id: int, data: APT_DATA_PACKET_TYPE) -> Non self._transport.write(msg) self._transport.write(data) - def ask(self, data_type: APT_DATA_PACKET_TYPE, timeout: Optional[float] = None) -> APT_DATA_PACKET_TYPE: """ Ask for a response. @@ -194,7 +209,9 @@ def ask(self, data_type: APT_DATA_PACKET_TYPE, timeout: Optional[float] = None) # Check that the received message ID is the ID that is expected. if data_type.MESSAGE_ID != header.message_id: - raise QMI_InstrumentException(f"Expected message with ID {data_type.MESSAGE_ID}, but received {header.message_id}") + raise QMI_InstrumentException( + f"Expected message with ID {data_type.MESSAGE_ID}, but received {header.message_id}" + ) # Read the data packet that follows the header. data_bytes = self._transport.read(nbytes=data_length, timeout=timeout) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 8f23ecab..8c625993 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -1,26 +1,38 @@ """Module for a Thorlabs MPC320 motorised fibre polarisation controller.""" + import logging from qmi.core.context import QMI_Context from qmi.core.exceptions import QMI_TimeoutException from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification from qmi.core.rpc import rpc_method from qmi.core.transport import create_transport -from qmi.instruments.thorlabs.apt_packets import HW_GET_INFO, MOD_GET_CHANENABLESTATE, MOT_GET_SET_POSCOUNTER, MOT_GET_USTATUSUPDATE, MOT_MOVE_COMPLETED, MOT_MOVE_HOMED, MOT_SET_EEPROMPARAMS, POL_GET_SET_PARAMS +from qmi.instruments.thorlabs.apt_packets import ( + HW_GET_INFO, + MOD_GET_CHANENABLESTATE, + MOT_GET_USTATUSUPDATE, + MOT_MOVE_COMPLETED, + MOT_MOVE_HOMED, + MOT_SET_EEPROMPARAMS, + POL_GET_SET_PARAMS, +) from qmi.instruments.thorlabs.apt_protocol import AptChannelState, AptMessageId, AptProtocol + # Global variable holding the logger for this module. _logger = logging.getLogger(__name__) + class Thorlabs_MPC320(QMI_Instrument): """ Driver for a Thorlabs MPC320 motorised fibre polarisation controller. """ + DEFAULT_RESPONSE_TIMEOUT = 1.0 - def __init__(self, - context: QMI_Context, - name: str, - transport: str - ) -> None: + # the maximum range for a paddle is 170 degrees + # the value returned by the encoder is 1370 for 170 degrees + ENCODER_CONVERSION_UNIT = 1370 / 170 + + def __init__(self, context: QMI_Context, name: str, transport: str) -> None: """Initialize the instrument driver. Parameters: @@ -49,7 +61,7 @@ def close(self) -> None: def get_idn(self) -> QMI_InstrumentIdentification: """ Read instrument type and version and return QMI_InstrumentIdentification instance. - + Returns: an instance of QMI_InstrumentIdentification. The version refers to the firmware version. """ @@ -60,7 +72,7 @@ def get_idn(self) -> QMI_InstrumentIdentification: # Get response resp = self._apt_protocol.ask(HW_GET_INFO) return QMI_InstrumentIdentification("Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version) - + @rpc_method def identify_channel(self, channel_number: int) -> None: """ @@ -86,7 +98,7 @@ def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> # TODO: check and validate channel number for MPC320 self._check_is_open() # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, channel_number, state.value) + self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, channel_number, state.value) @rpc_method def enable_channel(self, channel_number: int) -> None: @@ -117,7 +129,7 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: Parameters: channel_number: The channel to check. - + Returns: The state of the channel as an AptChannelState enum. """ @@ -128,7 +140,7 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: # Get response resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) return AptChannelState(resp.enable_state) - + @rpc_method def disconnect_hardware(self) -> None: """ @@ -159,55 +171,10 @@ def stop_auto_status_update(self) -> None: # Send message. self._apt_protocol.write_param_command(AptMessageId.HW_STOP_UPDATEMSGS.value) - @rpc_method - def restore_factory_settings(self) -> None: - """ - Restore settings to the default values stored in the EEPROM. - """ - _logger.info("[%s] Restoring factory settings of instrument", self._name) - self._check_is_open() - # Send message. - self._apt_protocol.write_param_command(AptMessageId.RESTOREFACTORYSETTINGS.value) - - @rpc_method - def set_position_counter(self, channel_number: int, position_counter: int) -> None: - """ - Set the live position count in the controller. - - Paramters: - channel_number: The channel to change the position counter for. - position_counter: The value of the position counter. - """ - _logger.info("[%s] Changing position counter of channel %d to %d", self._name, channel_number, position_counter) - self._check_is_open() - # Make data packet. - data_packet = MOT_GET_SET_POSCOUNTER(chan_ident=channel_number, position=position_counter) - # Send message. - self._apt_protocol.write_data_command(AptMessageId.MOT_SET_POSCOUNTER.value, data_packet) - - @rpc_method - def get_position_counter(self, channel_number: int) -> int: - """ - Get the value of the position counter for a channel. - - Paramters: - channel_number: The channel to query. - - Returns: - position counter for a channel. - """ - _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) - self._check_is_open() - # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_POSCOUNTER.value, channel_number) - # Get response - resp = self._apt_protocol.ask(MOT_GET_SET_POSCOUNTER) - return resp.position - @rpc_method def home_channel(self, channel_number: int) -> None: """ - Start the homing sequence for a given channel. + Start the homing sequence for a given channel. Paramters: channel_number: The channel to home. @@ -217,12 +184,11 @@ def home_channel(self, channel_number: int) -> None: # Send message. self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_HOME.value) - @rpc_method def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: """ Check if a given channel is homed. This command should only be run after the method `home_channel`. - Otherwise you will read bytes from other commands using this method. + Otherwise you will read bytes from other commands using this method. Paramters: channel_number: The channel to check. @@ -232,7 +198,7 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS Returns: True if the device was homed and a response was received before the timeout else False. """ - _logger.info("[%s] Check if channel %d is homed", self._name, channel_number) + _logger.info("[%s] Checking if channel %d is homed", self._name, channel_number) self._check_is_open() # Get response try: @@ -240,12 +206,12 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS return True except QMI_TimeoutException: return False - + @rpc_method def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: """ - Check if a given channel has completed its move. This command should only be run after a relative or absolute move command. - Otherwise you will read bytes from other commands using this method. + Check if a given channel has completed its move. This command should only be run after a relative or absolute + move command. Otherwise you will read bytes from other commands. Paramters: channel_number: The channel to check. @@ -256,7 +222,11 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON True if the move was completed and a response was received before the timeout else False. """ # TODO: add status data packet - _logger.info("[%s] Check if channel %d has completed its move", self._name, channel_number) + _logger.info( + "[%s] Checking if channel %d has completed its move", + self._name, + channel_number, + ) self._check_is_open() # Get response try: @@ -264,7 +234,7 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON return True except QMI_TimeoutException: return False - + @rpc_method def save_parameter_settings(self, channel_number: int, message_id: int) -> None: """ @@ -285,7 +255,8 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: @rpc_method def get_status_update(self, channel_number: int) -> MOT_GET_USTATUSUPDATE: """ - Get the status update for a given channel. + Get the status update for a given channel. This call will return the position, velocity, motor current and + status of the channel. Parameters: channel_number: The channel to query. @@ -299,9 +270,16 @@ def get_status_update(self, channel_number: int) -> MOT_GET_USTATUSUPDATE: self._apt_protocol.write_param_command(AptMessageId.MOT_GET_USTATUSUPDATE.value, channel_number) # Get response return self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) - + @rpc_method - def set_polarisation_parameters(self, velocity: int, home_pos: int, jog_step1: int, jog_step2: int, jog_step3: int) -> None: + def set_polarisation_parameters( + self, + velocity: int, + home_pos: int, + jog_step1: int, + jog_step2: int, + jog_step3: int, + ) -> None: """ Set the polarisation parameters. @@ -316,12 +294,18 @@ def set_polarisation_parameters(self, velocity: int, home_pos: int, jog_step1: i _logger.info("[%s] Setting polarisation parameters", self._name) self._check_is_open() # Make data packet. - data_packet = POL_GET_SET_PARAMS(velocity=velocity, home_position=home_pos, jog_step1=jog_step1, jog_step2=jog_step2, jog_step3=jog_step3) + data_packet = POL_GET_SET_PARAMS( + velocity=velocity, + home_position=home_pos, + jog_step1=jog_step1, + jog_step2=jog_step2, + jog_step3=jog_step3, + ) # Send message. self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) @rpc_method - def get_polarisation_parameters(self) -> MOT_GET_SET_POSCOUNTER: + def get_polarisation_parameters(self) -> POL_GET_SET_PARAMS: """ Get the polarisation parameters. """ @@ -330,4 +314,4 @@ def get_polarisation_parameters(self) -> MOT_GET_SET_POSCOUNTER: # Send request message. self._apt_protocol.write_param_command(AptMessageId.POL_REQ_PARAMS.value) # Get response - return self._apt_protocol.ask(MOT_GET_SET_POSCOUNTER) \ No newline at end of file + return self._apt_protocol.ask(POL_GET_SET_PARAMS) From 2010c09c8b6b473825b1b6d271d92e8996382a25 Mon Sep 17 00:00:00 2001 From: Badge Bot Date: Fri, 15 Mar 2024 18:12:06 +0000 Subject: [PATCH 04/37] commit badges --- .github/badges/coverage.svg | 4 ++-- .github/badges/mypy.svg | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index efe04809..d283ae09 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -17,7 +17,7 @@ coverage - 92% - 92% + 91% + 91% diff --git a/.github/badges/mypy.svg b/.github/badges/mypy.svg index 6e625d92..0411eb05 100644 --- a/.github/badges/mypy.svg +++ b/.github/badges/mypy.svg @@ -1,23 +1,23 @@ - + - + - - + + mypy mypy - pass - pass + fail + fail From ef125c7580c8d91288f940677623393ab16716a4 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 15 Mar 2024 21:50:37 +0100 Subject: [PATCH 05/37] pipeline fix to check failure --- .github/workflows/pull_request.yml | 63 ++++++------------------ qmi/instruments/thorlabs/apt_packets.py | 10 ++-- qmi/instruments/thorlabs/apt_protocol.py | 6 +-- 3 files changed, 25 insertions(+), 54 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 44fa24f2..f434d1a4 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -72,7 +72,7 @@ jobs: - name: mypy run: | pip install mypy - mypy --namespace-packages $SOURCE_DIRS | tee mypy.log + mypy --namespace-packages $SOURCE_DIRS > mypy.log if [ -n "$( tail -n 1 mypy.log | grep -e '^Succes' )" ]; then RESULT="pass"; else RESULT="fail"; fi if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l mypy -v $RESULT -f $BADGES_DIR/mypy.svg fail=red pass=green; fi - name: upload mypy test results @@ -81,52 +81,21 @@ jobs: name: mypy-results path: mypy.log if: ${{ always() }} - - name: radon-cc - run: | - pip install radon - chmod a+x scripts/run_analysis_cc.sh - MAX_SCORE=$( scripts/run_analysis_cc.sh $SOURCE_DIRS ) - echo $MAX_SCORE > cc_max_score - if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l radon-cc -v $MAX_SCORE -f $BADGES_DIR/radon-cc.svg 11=green 21=yellow 31=orange 100=red; fi - # bc evaluates to `1` if relation is true - exit $( echo "$MAX_SCORE > $COMPLEXITY_MAX_SCORE" | bc ) - MAX_SCORE=$( cat cc_max_score ) - - name: upload radon-cc results - uses: actions/upload-artifact@v3 - with: - name: radon-cc-results - path: radon-cc.log - if: ${{ always() }} - - name: radon-mi - run: | - pip install radon - chmod a+x scripts/run_analysis_mi.sh - MIN_INDEX=$( scripts/run_analysis_mi.sh $SOURCE_DIRS ) - echo $MIN_INDEX > mi_min_index - if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l radon-mi -v $MIN_INDEX -f $BADGES_DIR/radon-mi.svg 10=red 20=orange 100=green; fi - exit $( echo "$MIN_INDEX < $MAINTAINABILITY_MIN_INDEX" | bc ) # bc evaluates to `1` if relation is true - MIN_INDEX=$( cat mi_min_index ) - - name: upload radon-mi results - uses: actions/upload-artifact@v3 - with: - name: radon-mi-results - path: radon-mi.log - if: ${{ always() }} - - name: coverage - run: | - pip install coverage - if coverage run --branch --source=$SOURCE_DIRS -m unittest discover --start-directory=tests --pattern="test_*.py"; then RESULT="pass"; else RESULT="fail"; fi - coverage report --show-missing --fail-under=$COVERAGE_MIN_PERC | tee coverage.log - COVERAGE_PERC=$(tail -1 coverage.log | grep -o '....$') - if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l coverage -v $COVERAGE_PERC -f $BADGES_DIR/coverage.svg 60=red 80=orange 100=green; fi - if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l tests -v $RESULT -f $BADGES_DIR/tests.svg fail=red pass=green; fi - # coverage: '/^TOTAL.+?(\d+\%)$/' - - name: upload coverage results - uses: actions/upload-artifact@v3 - with: - name: coverage-results - path: coverage.log - if: ${{ always() }} + # - name: coverage + # run: | + # pip install coverage + # if coverage run --branch --source=$SOURCE_DIRS -m unittest discover --start-directory=tests --pattern="test_*.py"; then RESULT="pass"; else RESULT="fail"; fi + # coverage report --show-missing --fail-under=$COVERAGE_MIN_PERC | tee coverage.log + # COVERAGE_PERC=$(tail -1 coverage.log | grep -o '....$') + # if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l coverage -v $COVERAGE_PERC -f $BADGES_DIR/coverage.svg 60=red 80=orange 100=green; fi + # if [ "${{ matrix.python-version }}" = "3.10" ]; then anybadge -o -l tests -v $RESULT -f $BADGES_DIR/tests.svg fail=red pass=green; fi + # # coverage: '/^TOTAL.+?(\d+\%)$/' + # - name: upload coverage results + # uses: actions/upload-artifact@v3 + # with: + # name: coverage-results + # path: coverage.log + # if: ${{ always() }} - name: push all internal commits run: | if [ "${{ matrix.python-version }}" = "3.10" ]; then git add $BADGES_DIR/pylint.svg; fi diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index a27375f6..d4e41be6 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -45,7 +45,8 @@ class HW_GET_INFO(AptDataPacket): class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): """ - Header structure for the MOD_GET_CHANENABLESTATE response. This header is sent as a response to MOD_REQ_CHANENABLESTATE. + Header structure for the MOD_GET_CHANENABLESTATE response. This header is sent as a response to + MOD_REQ_CHANENABLESTATE. Fields: message_id: ID of message. @@ -67,7 +68,8 @@ class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): class MOT_MOVE_HOMED(AptMessageHeaderWithParams): """ - Header structure for the MOT_MOVE_HOMED response. This header is sent as a response to MOT_MOVE_HOME once homing is complete. + Header structure for the MOT_MOVE_HOMED response. This header is sent as a response to MOT_MOVE_HOME + once homing is complete. Fields: message_id: ID of message. @@ -89,8 +91,8 @@ class MOT_MOVE_HOMED(AptMessageHeaderWithParams): class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): """ - Header structure for the MOT_MOVE_COMPLETED response. This header is sent as a response to a relative or absolute move command - once the move has been completed. + Header structure for the MOT_MOVE_COMPLETED response. This header is sent as a response to a relative or absolute + move command once the move has been completed. Fields: message_id: ID of message. diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index e45cf81d..f390f50d 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -10,7 +10,7 @@ from qmi.core.transport import QMI_Transport from qmi.core.exceptions import QMI_InstrumentException -APT_DATA_PACKET_TYPE = TypeVar("APT_DATA_PACKET_TYPE", bound="AptDataPacket") +T = TypeVar("T", bound="AptDataPacket") # APT format specifiers apt_word = c_uint16 @@ -173,7 +173,7 @@ def write_param_command(self, message_id: int, param1: Optional[int] = None, par # Send command. self._transport.write(msg) - def write_data_command(self, message_id: int, data: APT_DATA_PACKET_TYPE) -> None: + def write_data_command(self, message_id: int, data: T) -> None: """ Send and APT protocol command with data. """ @@ -187,7 +187,7 @@ def write_data_command(self, message_id: int, data: APT_DATA_PACKET_TYPE) -> Non self._transport.write(msg) self._transport.write(data) - def ask(self, data_type: APT_DATA_PACKET_TYPE, timeout: Optional[float] = None) -> APT_DATA_PACKET_TYPE: + def ask(self, data_type: T, timeout: Optional[float] = None) -> T: """ Ask for a response. From d9bcdb7378267004b5e4b35bdfa11a9dc6bfcd60 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Wed, 20 Mar 2024 16:48:54 +0100 Subject: [PATCH 06/37] added move absolute command --- qmi/instruments/thorlabs/apt_packets.py | 34 +++++++++----- qmi/instruments/thorlabs/apt_protocol.py | 27 ++++++------ qmi/instruments/thorlabs/mpc320.py | 56 +++++++++++++++++++----- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index d4e41be6..9fc6b30e 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -2,8 +2,7 @@ from typing import List, Tuple from qmi.instruments.thorlabs.apt_protocol import ( - AptDataPacket, - AptMessageHeaderWithParams, + AptMessage, AptMessageId, apt_long, apt_dword, @@ -13,7 +12,7 @@ ) -class HW_GET_INFO(AptDataPacket): +class HW_GET_INFO(AptMessage): """ Data packet structure for the HW_GET_INFO response. This packet is sent as a response to HW_GET_INFO. @@ -43,7 +42,7 @@ class HW_GET_INFO(AptDataPacket): ] -class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): +class MOD_GET_CHANENABLESTATE(AptMessage): """ Header structure for the MOD_GET_CHANENABLESTATE response. This header is sent as a response to MOD_REQ_CHANENABLESTATE. @@ -57,6 +56,7 @@ class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): """ MESSAGE_ID = AptMessageId.MOD_GET_CHANENABLESTATE.value + HEADER_ONLY = True _fields_: List[Tuple[str, type]] = [ ("message_id", apt_word), ("chan_ident", apt_byte), @@ -66,7 +66,7 @@ class MOD_GET_CHANENABLESTATE(AptMessageHeaderWithParams): ] -class MOT_MOVE_HOMED(AptMessageHeaderWithParams): +class MOT_MOVE_HOMED(AptMessage): """ Header structure for the MOT_MOVE_HOMED response. This header is sent as a response to MOT_MOVE_HOME once homing is complete. @@ -80,6 +80,7 @@ class MOT_MOVE_HOMED(AptMessageHeaderWithParams): """ MESSAGE_ID = AptMessageId.MOT_MOVE_HOMED.value + HEADER_ONLY = True _fields_: List[Tuple[str, type]] = [ ("message_id", apt_word), ("chan_ident", apt_byte), @@ -88,8 +89,20 @@ class MOT_MOVE_HOMED(AptMessageHeaderWithParams): ("source", apt_byte), ] +class MOT_MOVE_ABSOLUTE(AptMessage): + """ + Data packet structure for a MOT_SMOVE_ABSOLUTE command. + + Fields: + chan_ident: The channel being addressed. + absolute_distance: The distance to move in encoder units. + """ + + MESSAGE_ID = AptMessageId.MOT_SET_EEPROMPARAMS.value + _fields_: List[Tuple[str, type]] = [("chan_ident", apt_word), ("absolute_distance", apt_long)] + -class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): +class MOT_MOVE_COMPLETED(AptMessage): """ Header structure for the MOT_MOVE_COMPLETED response. This header is sent as a response to a relative or absolute move command once the move has been completed. @@ -103,6 +116,7 @@ class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): """ MESSAGE_ID = AptMessageId.MOT_MOVE_COMPLETED.value + HEADER_ONLY = True _fields_: List[Tuple[str, type]] = [ ("message_id", apt_word), ("chan_ident", apt_byte), @@ -112,7 +126,7 @@ class MOT_MOVE_COMPLETED(AptMessageHeaderWithParams): ] -class MOT_GET_USTATUSUPDATE(AptDataPacket): +class MOT_GET_USTATUSUPDATE(AptMessage): """ Data packet structure for a MOT_GET_USTATUSUPDATE command. @@ -124,7 +138,7 @@ class MOT_GET_USTATUSUPDATE(AptDataPacket): status_bits: Status bits that provide various errors and indications. """ - MESSAGE_ID = AptMessageId.MOT_REQ_USTATUSUPDATE.value + MESSAGE_ID = AptMessageId.MOT_GET_USTATUSUPDATE.value _fields_: List[Tuple[str, type]] = [ ("chan_ident", apt_word), ("position", apt_long), @@ -134,7 +148,7 @@ class MOT_GET_USTATUSUPDATE(AptDataPacket): ] -class MOT_SET_EEPROMPARAMS(AptDataPacket): +class MOT_SET_EEPROMPARAMS(AptMessage): """ Data packet structure for a MOT_SET_EEPROMPARAMS command. @@ -147,7 +161,7 @@ class MOT_SET_EEPROMPARAMS(AptDataPacket): _fields_: List[Tuple[str, type]] = [("chan_ident", apt_word), ("msg_id", apt_word)] -class POL_GET_SET_PARAMS(AptDataPacket): +class POL_GET_SET_PARAMS(AptMessage): """ " Data packet structure for POL_SET_PARAMS command. It is also the data packet structure for the POL_SET_PARAMS. diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index f390f50d..08af343c 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -5,13 +5,11 @@ from ctypes import LittleEndianStructure, c_uint8, c_uint16, c_int16, c_uint32, c_int32, c_char, sizeof from enum import Enum -from typing import List, Optional, Tuple, TypeVar +from typing import List, Optional, Tuple, Type, TypeVar from qmi.core.transport import QMI_Transport from qmi.core.exceptions import QMI_InstrumentException -T = TypeVar("T", bound="AptDataPacket") - # APT format specifiers apt_word = c_uint16 apt_short = c_int16 @@ -72,6 +70,7 @@ class AptMessageId(Enum): HW_STOP_UPDATEMSGS = 0x0012 MOT_MOVE_HOME = 0x0443 MOT_MOVE_HOMED = 0x0444 + MOT_MOVE_ABSOLUTE = 0x0453 MOT_MOVE_COMPLETED = 0x0464 MOT_SET_EEPROMPARAMS = 0x04B9 MOT_REQ_USTATUSUPDATE = 0x0490 @@ -117,14 +116,16 @@ class AptMessageHeaderForData(LittleEndianStructure): ] -class AptDataPacket(LittleEndianStructure): +class AptMessage(LittleEndianStructure): """ - Base class for APT data packet. + Base class for an APT message. """ MESSAGE_ID: int + HEADER_ONLY: bool = False _pack_ = True +T = TypeVar("T", bound=AptMessage) class AptProtocol: """ @@ -169,9 +170,8 @@ def write_param_command(self, message_id: int, param1: Optional[int] = None, par msg = AptMessageHeaderWithParams( message_id, param1 or 0x00, param2 or 0x00, self._apt_device_address, self._host_address ) - # Send command. - self._transport.write(msg) + self._transport.write(bytearray(msg)) def write_data_command(self, message_id: int, data: T) -> None: """ @@ -181,13 +181,12 @@ def write_data_command(self, message_id: int, data: T) -> None: data_length = sizeof(data) # Make the header. - msg = AptMessageHeaderWithParams(message_id, data_length, self._apt_device_address | 0x80, self._host_address) + msg = AptMessageHeaderForData(message_id, data_length, self._apt_device_address | 0x80, self._host_address) # Send the header and data packet. - self._transport.write(msg) - self._transport.write(data) + self._transport.write(bytearray(msg) + bytearray(data)) - def ask(self, data_type: T, timeout: Optional[float] = None) -> T: + def ask(self, data_type: Type[T], timeout: Optional[float] = None) -> T: """ Ask for a response. @@ -203,8 +202,10 @@ def ask(self, data_type: T, timeout: Optional[float] = None) -> T: timeout = self._timeout # Read the header first. - resp = self._transport.read(nbytes=self.HEADER_SIZE_BYTES, timeout=timeout) - header = AptMessageHeaderForData.from_buffer_copy(resp) + header_bytes = self._transport.read(nbytes=self.HEADER_SIZE_BYTES, timeout=timeout) + if data_type.HEADER_ONLY: + return data_type.from_buffer_copy(header_bytes) + header = AptMessageHeaderForData.from_buffer_copy(header_bytes) data_length = header.date_length # Check that the received message ID is the ID that is expected. diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 8c625993..946c1447 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -1,5 +1,6 @@ """Module for a Thorlabs MPC320 motorised fibre polarisation controller.""" +from dataclasses import dataclass import logging from qmi.core.context import QMI_Context from qmi.core.exceptions import QMI_TimeoutException @@ -10,6 +11,7 @@ HW_GET_INFO, MOD_GET_CHANENABLESTATE, MOT_GET_USTATUSUPDATE, + MOT_MOVE_ABSOLUTE, MOT_MOVE_COMPLETED, MOT_MOVE_HOMED, MOT_SET_EEPROMPARAMS, @@ -20,6 +22,22 @@ # Global variable holding the logger for this module. _logger = logging.getLogger(__name__) +@dataclass +class Thorlabs_MPC320_Status: + """ + Data class for the status of the MPC320 + + Attributes: + channel: Channel number. + position: Absolute position of the channel in degrees. + velocity: Velocity in controller units. + motor_current: Current of motor in mA + """ + channel: int + position: float + velocity: int + motor_current: int + class Thorlabs_MPC320(QMI_Instrument): """ @@ -30,7 +48,7 @@ class Thorlabs_MPC320(QMI_Instrument): # the maximum range for a paddle is 170 degrees # the value returned by the encoder is 1370 for 170 degrees - ENCODER_CONVERSION_UNIT = 1370 / 170 + ENCODER_CONVERSION_UNIT = 170/1370 def __init__(self, context: QMI_Context, name: str, transport: str) -> None: """Initialize the instrument driver. @@ -206,6 +224,26 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS return True except QMI_TimeoutException: return False + + @rpc_method + def move_absolute(self, channel_number: int, position: float) -> None: + """ + Move a channel to the specified position. The specified position is in degeres. A conversion is done to convert this + into encoder counts. This means that there may be a slight mismatch in the specified position and the actual position. + You may use the get_status_update method to get the actual position. + + Parameters: + channel_number: The channel to address. + position: Absolute position to move to in degrees. + """ + _logger.info("[%s] Moving channel %d", self._name, channel_number) + self._check_is_open() + # Convert position in degrees to encoder counts. + encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) + # Make data packet. + data_packet = MOT_MOVE_ABSOLUTE(chan_ident=channel_number, absolute_distance=encoder_position) + # Send message. + self._apt_protocol.write_data_command(AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet) @rpc_method def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: @@ -222,11 +260,7 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON True if the move was completed and a response was received before the timeout else False. """ # TODO: add status data packet - _logger.info( - "[%s] Checking if channel %d has completed its move", - self._name, - channel_number, - ) + _logger.info("[%s] Checking if channel %d has completed its move", self._name, channel_number) self._check_is_open() # Get response try: @@ -253,7 +287,7 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: self._apt_protocol.write_data_command(AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet) @rpc_method - def get_status_update(self, channel_number: int) -> MOT_GET_USTATUSUPDATE: + def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: """ Get the status update for a given channel. This call will return the position, velocity, motor current and status of the channel. @@ -262,14 +296,16 @@ def get_status_update(self, channel_number: int) -> MOT_GET_USTATUSUPDATE: channel_number: The channel to query. Returns: - An instance of MOT_GET_USTATUSUPDATE. + An instance of Thorlabs_MPC320_Status. """ _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) self._check_is_open() # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOT_GET_USTATUSUPDATE.value, channel_number) + self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_USTATUSUPDATE.value, channel_number) # Get response - return self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) + resp = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) + return Thorlabs_MPC320_Status(channel=channel_number, position=resp.position * self.ENCODER_CONVERSION_UNIT, + velocity=resp.velocity, motor_current=resp.motor_current) @rpc_method def set_polarisation_parameters( From dd026c2cb09e42d6304329460f263c631a99aadd Mon Sep 17 00:00:00 2001 From: Badge Bot Date: Wed, 20 Mar 2024 15:55:25 +0000 Subject: [PATCH 07/37] commit badges --- .github/badges/mypy.svg | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/badges/mypy.svg b/.github/badges/mypy.svg index 0411eb05..6e625d92 100644 --- a/.github/badges/mypy.svg +++ b/.github/badges/mypy.svg @@ -1,23 +1,23 @@ - + - + - - + + mypy mypy - fail - fail + pass + pass From 34687cc372291d126983cbeb5159932acc1117c9 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 22 Mar 2024 15:22:54 +0100 Subject: [PATCH 08/37] fixed polarisation commands --- qmi/instruments/thorlabs/apt_packets.py | 2 +- qmi/instruments/thorlabs/mpc320.py | 85 +++++++++++++++++++++---- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index 9fc6b30e..ffcfd6dd 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -174,7 +174,7 @@ class POL_GET_SET_PARAMS(AptMessage): jog_step3: Size fo jog step to be performed on paddle 3. """ - MESSAGE_ID = AptMessageId.POL_SET_PARAMS.value + MESSAGE_ID = AptMessageId.POL_GET_PARAMS.value _fields_: List[Tuple[str, type]] = [ ("not_used", apt_word), ("velocity", apt_word), diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 946c1447..21cda366 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import logging from qmi.core.context import QMI_Context -from qmi.core.exceptions import QMI_TimeoutException +from qmi.core.exceptions import QMI_InstrumentException, QMI_TimeoutException from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification from qmi.core.rpc import rpc_method from qmi.core.transport import create_transport @@ -38,6 +38,24 @@ class Thorlabs_MPC320_Status: velocity: int motor_current: int +@dataclass +class Thorlabs_MPC320_PolarisationParameters: + """ + Data class for the polarisation parameters of the MPC320 + + Attributes: + velocity: The velocity in percentage of the max velocity 400 degrees/s. + home_position: The home position of all the paddles/channels in degrees. + jog_step1: The position to move paddel/channel 1 by for a jog step in degrees. + jog_step2: The position to move paddel/channel 2 by for a jog step in degrees. + jog_step3: The position to move paddel/channel 3 by for a jog step in degrees. + """ + velocity: float + home_position: float + jog_step1: float + jog_step2: float + jog_step3: float + class Thorlabs_MPC320(QMI_Instrument): """ @@ -50,6 +68,12 @@ class Thorlabs_MPC320(QMI_Instrument): # the value returned by the encoder is 1370 for 170 degrees ENCODER_CONVERSION_UNIT = 170/1370 + MIN_POSITION = 0 + MAX_POSITION = 170 + + MIN_VELOCITY_PERC = 10 + MAX_VELOCITY_PERC = 100 + def __init__(self, context: QMI_Context, name: str, transport: str) -> None: """Initialize the instrument driver. @@ -60,7 +84,32 @@ def __init__(self, context: QMI_Context, name: str, transport: str) -> None: super().__init__(context, name) self._transport = create_transport(transport, default_attributes={"baudrate": 115200, "rtscts": True}) self._apt_protocol = AptProtocol(self._transport, default_timeout=self.DEFAULT_RESPONSE_TIMEOUT) - self._power_unit_configured = False + + def _validate_position(self, pos: float) -> None: + """ + Validate the position. Any position for the MPC320 needs to be in the range 0 to 170 degrees, or 0 to 1370 in encoder counts. + + Parameters: + pos: Position to validate in degrees. + + Raises: + an instance of QMI_InstrumentException if the position is invalid. + """ + if not self.MIN_POSITION <= pos <= self.MAX_POSITION: + raise QMI_InstrumentException(f"Given position {pos} is outside the valid range [{self.MIN_POSITION}, {self.MAX_POSITION}]") + + def _validate_velocity(self, vel: float) -> None: + """ + Validate the velocity. Any velocity for the MPC320 needs to be in the range 40 to 400 degrees/s, or 10 to 100% of 400 degrees/s. + + Parameters: + vel: Velocity to validate in percentage. + + Raises: + an instance of QMI_InstrumentException if the velocity is invalid. + """ + if not self.MIN_VELOCITY_PERC <= vel <= self.MAX_VELOCITY_PERC: + raise QMI_InstrumentException(f"Given relative velocity {vel} is outside the valid range [{self.MIN_VELOCITY_PERC}%, {self.MAX_VELOCITY_PERC}%]") @rpc_method def open(self) -> None: @@ -321,21 +370,26 @@ def set_polarisation_parameters( Parameters: velocity: Velocity in range 10% to 100% of 400 degrees/s. - home_position: Home position in encoder counts. - jog_step1: Size fo jog step to be performed on paddle 1. - jog_step2: Size fo jog step to be performed on paddle 2. - jog_step3: Size fo jog step to be performed on paddle 3. + home_position: Home position in degrees. + jog_step1: Size of jog step for paddle 1. + jog_step2: Size of jog step for paddle 2. + jog_step3: Size of jog step for paddle 3. """ - # TODO: finish command _logger.info("[%s] Setting polarisation parameters", self._name) self._check_is_open() + # Validate parameters. + self._validate_velocity(velocity) + self._validate_position(home_pos) + self._validate_position(jog_step1) + self._validate_position(jog_step2) + self._validate_position(jog_step3) # Make data packet. data_packet = POL_GET_SET_PARAMS( velocity=velocity, - home_position=home_pos, - jog_step1=jog_step1, - jog_step2=jog_step2, - jog_step3=jog_step3, + home_position=round(home_pos / self.ENCODER_CONVERSION_UNIT), + jog_step1=round(jog_step1 / self.ENCODER_CONVERSION_UNIT), + jog_step2=round(jog_step2 / self.ENCODER_CONVERSION_UNIT), + jog_step3=round(jog_step3 / self.ENCODER_CONVERSION_UNIT), ) # Send message. self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) @@ -349,5 +403,10 @@ def get_polarisation_parameters(self) -> POL_GET_SET_PARAMS: self._check_is_open() # Send request message. self._apt_protocol.write_param_command(AptMessageId.POL_REQ_PARAMS.value) - # Get response - return self._apt_protocol.ask(POL_GET_SET_PARAMS) + # Get response. + params = self._apt_protocol.ask(POL_GET_SET_PARAMS) + return Thorlabs_MPC320_PolarisationParameters(velocity=params.velocity, + home_position=params.home_position * self.ENCODER_CONVERSION_UNIT, + jog_step1=params.jog_step1 * self.ENCODER_CONVERSION_UNIT, + jog_step2=params.jog_step2 * self.ENCODER_CONVERSION_UNIT, + jog_step3=params.jog_step3 * self.ENCODER_CONVERSION_UNIT) From 04f9cb39804e6e8ef20b3d7bfa9b7223eb9c7c6c Mon Sep 17 00:00:00 2001 From: Badge Bot Date: Fri, 22 Mar 2024 14:28:34 +0000 Subject: [PATCH 09/37] commit badges --- .github/badges/mypy.svg | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/badges/mypy.svg b/.github/badges/mypy.svg index 6e625d92..0411eb05 100644 --- a/.github/badges/mypy.svg +++ b/.github/badges/mypy.svg @@ -1,23 +1,23 @@ - + - + - - + + mypy mypy - pass - pass + fail + fail From 56d4f7195a6e178777c1988dbbb3a63aed53c4f3 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 22 Mar 2024 15:29:53 +0100 Subject: [PATCH 10/37] added channel validation --- qmi/instruments/thorlabs/mpc320.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 21cda366..eb350bb5 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -74,6 +74,9 @@ class Thorlabs_MPC320(QMI_Instrument): MIN_VELOCITY_PERC = 10 MAX_VELOCITY_PERC = 100 + MIN_CHANNEL_NUMBER = 1 + MAX_CHANNEL_NUMBER = 3 + def __init__(self, context: QMI_Context, name: str, transport: str) -> None: """Initialize the instrument driver. @@ -110,6 +113,20 @@ def _validate_velocity(self, vel: float) -> None: """ if not self.MIN_VELOCITY_PERC <= vel <= self.MAX_VELOCITY_PERC: raise QMI_InstrumentException(f"Given relative velocity {vel} is outside the valid range [{self.MIN_VELOCITY_PERC}%, {self.MAX_VELOCITY_PERC}%]") + + def _validate_channel(self, channel_number: int) -> None: + """ + Validate the channel number. The MPC320 has 3 channels. + + Parameters: + channel_number: Channel number to validate. + + Raises: + an instance of QMI_InstrumentException if the channel is not 1, 2 or 3 + """ + + if channel_number not in [1, 2, 3]: + raise QMI_InstrumentException(f"Given channel {channel_number} is not in the valid range [{self.MIN_CHANNEL_NUMBER}, {self.MAX_CHANNEL_NUMBER}]") @rpc_method def open(self) -> None: @@ -149,7 +166,7 @@ def identify_channel(self, channel_number: int) -> None: channel_number: The channel to be identified. """ _logger.info("[%s] Identify channel %d", self._name, channel_number) - # TODO: check and validate channel number for MPC320 + self._validate_channel(channel_number) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, channel_number) @@ -162,8 +179,8 @@ def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> channel_number: The channel to toggle. state: The state to change the channel to. """ - # TODO: check and validate channel number for MPC320 self._check_is_open() + self._validate_channel(channel_number) # Send message. self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, channel_number, state.value) @@ -201,6 +218,7 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: The state of the channel as an AptChannelState enum. """ _logger.info("[%s] Getting state of channel %d", self._name, channel_number) + self._validate_channel(channel_number) self._check_is_open() # Send request message. self._apt_protocol.write_param_command(AptMessageId.MOD_REQ_CHANENABLESTATE.value, channel_number) @@ -247,6 +265,7 @@ def home_channel(self, channel_number: int) -> None: channel_number: The channel to home. """ _logger.info("[%s] Homing channel %d", self._name, channel_number) + self._validate_channel(channel_number) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_HOME.value) @@ -266,6 +285,7 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS True if the device was homed and a response was received before the timeout else False. """ _logger.info("[%s] Checking if channel %d is homed", self._name, channel_number) + self._validate_channel(channel_number) self._check_is_open() # Get response try: @@ -286,6 +306,7 @@ def move_absolute(self, channel_number: int, position: float) -> None: position: Absolute position to move to in degrees. """ _logger.info("[%s] Moving channel %d", self._name, channel_number) + self._validate_channel(channel_number) self._check_is_open() # Convert position in degrees to encoder counts. encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) @@ -310,6 +331,7 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON """ # TODO: add status data packet _logger.info("[%s] Checking if channel %d has completed its move", self._name, channel_number) + self._validate_channel(channel_number) self._check_is_open() # Get response try: @@ -330,6 +352,7 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: """ _logger.info("[%s] Saving parameters of message %d", self._name, message_id) self._check_is_open() + self._validate_channel(channel_number) # Make data packet. data_packet = MOT_SET_EEPROMPARAMS(chan_ident=channel_number, msg_id=message_id) # Send message. @@ -349,6 +372,7 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: """ _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) self._check_is_open() + self._validate_channel(channel_number) # Send request message. self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_USTATUSUPDATE.value, channel_number) # Get response From cd9d552d15474799290136cdf7a7541d07f10d98 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 22 Mar 2024 15:31:03 +0100 Subject: [PATCH 11/37] refactor --- qmi/instruments/thorlabs/mpc320.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index eb350bb5..1c499509 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -68,8 +68,8 @@ class Thorlabs_MPC320(QMI_Instrument): # the value returned by the encoder is 1370 for 170 degrees ENCODER_CONVERSION_UNIT = 170/1370 - MIN_POSITION = 0 - MAX_POSITION = 170 + MIN_POSITION_DEGREES = 0 + MAX_POSITION_DEGREES = 170 MIN_VELOCITY_PERC = 10 MAX_VELOCITY_PERC = 100 @@ -98,8 +98,8 @@ def _validate_position(self, pos: float) -> None: Raises: an instance of QMI_InstrumentException if the position is invalid. """ - if not self.MIN_POSITION <= pos <= self.MAX_POSITION: - raise QMI_InstrumentException(f"Given position {pos} is outside the valid range [{self.MIN_POSITION}, {self.MAX_POSITION}]") + if not self.MIN_POSITION_DEGREES <= pos <= self.MAX_POSITION_DEGREES: + raise QMI_InstrumentException(f"Given position {pos} is outside the valid range [{self.MIN_POSITION_DEGREES}, {self.MAX_POSITION_DEGREES}]") def _validate_velocity(self, vel: float) -> None: """ From 3f80d4a130bf8a773e820732e1c7faa9c2af0334 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 22 Mar 2024 17:50:00 +0100 Subject: [PATCH 12/37] added channel mapping --- qmi/instruments/thorlabs/apt_packets.py | 2 +- qmi/instruments/thorlabs/mpc320.py | 46 +++++++++++++------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index ffcfd6dd..ac3025dc 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -98,7 +98,7 @@ class MOT_MOVE_ABSOLUTE(AptMessage): absolute_distance: The distance to move in encoder units. """ - MESSAGE_ID = AptMessageId.MOT_SET_EEPROMPARAMS.value + MESSAGE_ID = AptMessageId.MOT_MOVE_ABSOLUTE.value _fields_: List[Tuple[str, type]] = [("chan_ident", apt_word), ("absolute_distance", apt_long)] diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 1c499509..e4f239bd 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -2,8 +2,9 @@ from dataclasses import dataclass import logging +from typing import Dict from qmi.core.context import QMI_Context -from qmi.core.exceptions import QMI_InstrumentException, QMI_TimeoutException +from qmi.core.exceptions import QMI_InstrumentException from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification from qmi.core.rpc import rpc_method from qmi.core.transport import create_transport @@ -56,6 +57,12 @@ class Thorlabs_MPC320_PolarisationParameters: jog_step2: float jog_step3: float +Thorlabs_MPC320_ChannelMap: Dict[int, int] = { + 1: 0x01, + 2: 0x02, + 3: 0x04 +} + class Thorlabs_MPC320(QMI_Instrument): """ @@ -169,7 +176,7 @@ def identify_channel(self, channel_number: int) -> None: self._validate_channel(channel_number) self._check_is_open() # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, channel_number) + self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, Thorlabs_MPC320_ChannelMap[channel_number]) def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> None: """ @@ -182,7 +189,7 @@ def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> self._check_is_open() self._validate_channel(channel_number) # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, channel_number, state.value) + self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number], state.value) @rpc_method def enable_channel(self, channel_number: int) -> None: @@ -221,7 +228,7 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: self._validate_channel(channel_number) self._check_is_open() # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOD_REQ_CHANENABLESTATE.value, channel_number) + self._apt_protocol.write_param_command(AptMessageId.MOD_REQ_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number]) # Get response resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) return AptChannelState(resp.enable_state) @@ -268,7 +275,7 @@ def home_channel(self, channel_number: int) -> None: self._validate_channel(channel_number) self._check_is_open() # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_HOME.value) + self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_HOME.value, Thorlabs_MPC320_ChannelMap[channel_number]) @rpc_method def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: @@ -282,17 +289,15 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS and is set to a default value of DEFAULT_RESPONSE_TIMEOUT. Returns: - True if the device was homed and a response was received before the timeout else False. + True if the channel was homed. """ _logger.info("[%s] Checking if channel %d is homed", self._name, channel_number) self._validate_channel(channel_number) self._check_is_open() - # Get response - try: - _ = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) - return True - except QMI_TimeoutException: - return False + # Get response. + resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) + # Check if the channel number in the response is equal to the one that was asked for. + return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] @rpc_method def move_absolute(self, channel_number: int, position: float) -> None: @@ -311,7 +316,7 @@ def move_absolute(self, channel_number: int, position: float) -> None: # Convert position in degrees to encoder counts. encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) # Make data packet. - data_packet = MOT_MOVE_ABSOLUTE(chan_ident=channel_number, absolute_distance=encoder_position) + data_packet = MOT_MOVE_ABSOLUTE(chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], absolute_distance=encoder_position) # Send message. self._apt_protocol.write_data_command(AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet) @@ -327,18 +332,15 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON and is set to a default value of DEFAULT_RESPONSE_TIMEOUT. Returns: - True if the move was completed and a response was received before the timeout else False. + True if the move for the channel was completed. """ # TODO: add status data packet _logger.info("[%s] Checking if channel %d has completed its move", self._name, channel_number) self._validate_channel(channel_number) self._check_is_open() # Get response - try: - _ = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) - return True - except QMI_TimeoutException: - return False + resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) + return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] @rpc_method def save_parameter_settings(self, channel_number: int, message_id: int) -> None: @@ -354,7 +356,7 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: self._check_is_open() self._validate_channel(channel_number) # Make data packet. - data_packet = MOT_SET_EEPROMPARAMS(chan_ident=channel_number, msg_id=message_id) + data_packet = MOT_SET_EEPROMPARAMS(chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], msg_id=message_id) # Send message. self._apt_protocol.write_data_command(AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet) @@ -374,7 +376,7 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: self._check_is_open() self._validate_channel(channel_number) # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_USTATUSUPDATE.value, channel_number) + self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_USTATUSUPDATE.value, Thorlabs_MPC320_ChannelMap[channel_number]) # Get response resp = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) return Thorlabs_MPC320_Status(channel=channel_number, position=resp.position * self.ENCODER_CONVERSION_UNIT, @@ -419,7 +421,7 @@ def set_polarisation_parameters( self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) @rpc_method - def get_polarisation_parameters(self) -> POL_GET_SET_PARAMS: + def get_polarisation_parameters(self) -> Thorlabs_MPC320_PolarisationParameters: """ Get the polarisation parameters. """ From 91110cf6c550181d992ad0c2ff7e8a3aedc83e0b Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Wed, 27 Mar 2024 17:22:53 +0100 Subject: [PATCH 13/37] fixed enable and disable channel commands --- qmi/instruments/thorlabs/mpc320.py | 42 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index e4f239bd..aa791eac 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import logging -from typing import Dict +from typing import Dict, List from qmi.core.context import QMI_Context from qmi.core.exceptions import QMI_InstrumentException from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification @@ -192,26 +192,38 @@ def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number], state.value) @rpc_method - def enable_channel(self, channel_number: int) -> None: + def enable_channels(self, channel_numbers: List[int]) -> None: """ - Enable the channel. + Enable the channel(s). Note that this method will disable any channel that is not provided as an argument. For example, if + you enable channel 1, then 2 and 3 will be disabled. If you have previously enabled a channel(s) and fail + to include it/them again in this call, that channel(s) will be disabled. For example, if you run the following: + self.enable_channel([1]) + self.enable_channel([2]) + only channel 2 will be enabled and 1 and 3 will be disabled. The correct way to call this method in this case is + self.enable_channel([1,2]) Parameters: - channel_number: The channel to enable. + channel_number: The channnels(s) to enable. """ - _logger.info("[%s] Enabling channel %d", self._name, channel_number) - self._toggle_channel_state(channel_number, AptChannelState.ENABLE) + _logger.info("[%s] Enabling channel(s) %s", self._name, str(channel_numbers)) + self._check_is_open() + for channel_number in channel_numbers: + self._validate_channel(channel_number) + # Make hexadecimal value for channels + channels_to_enable = 0x00 + for channel_number in channel_numbers: + channels_to_enable ^= channel_number + # Send message. + self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, channels_to_enable, AptChannelState.ENABLE.value) @rpc_method - def disable_channel(self, channel_number: int) -> None: + def disable_all_channels(self) -> None: """ - Disable the channel. - - Parameters: - channel_number: The channel to disable. + Disable all the channels. """ - _logger.info("[%s] Disabling channel %d", self._name, channel_number) - self._toggle_channel_state(channel_number, AptChannelState.DISABLE) + _logger.info("[%s] Disabling channels", self._name) + self._check_is_open() + self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, 0x00, AptChannelState.ENABLE.value) @rpc_method def get_channel_state(self, channel_number: int) -> AptChannelState: @@ -231,6 +243,9 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: self._apt_protocol.write_param_command(AptMessageId.MOD_REQ_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number]) # Get response resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) + # For the MPC320 the state 0x00 is also a valid channel state. It is also the disable state + if resp.enable_state == 0x00: + return AptChannelState.DISABLE return AptChannelState(resp.enable_state) @rpc_method @@ -310,6 +325,7 @@ def move_absolute(self, channel_number: int, position: float) -> None: channel_number: The channel to address. position: Absolute position to move to in degrees. """ + # TODO: check for move completed command, otherwise the that message will stay in the buffer _logger.info("[%s] Moving channel %d", self._name, channel_number) self._validate_channel(channel_number) self._check_is_open() From 62612f7b2e92eac89c1bda4a96ba661965cc7bd6 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Wed, 27 Mar 2024 17:25:00 +0100 Subject: [PATCH 14/37] refactored identify command --- qmi/instruments/thorlabs/mpc320.py | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index aa791eac..8f968842 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -165,31 +165,16 @@ def get_idn(self) -> QMI_InstrumentIdentification: return QMI_InstrumentIdentification("Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version) @rpc_method - def identify_channel(self, channel_number: int) -> None: + def identify(self) -> None: """ - Identify a channel by flashing the front panel LEDs. - - Parameters: - channel_number: The channel to be identified. + Identify device by flashing the front panel LEDs. """ - _logger.info("[%s] Identify channel %d", self._name, channel_number) - self._validate_channel(channel_number) + _logger.info("[%s] Identifying device", self._name) self._check_is_open() # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, Thorlabs_MPC320_ChannelMap[channel_number]) - - def _toggle_channel_state(self, channel_number: int, state: AptChannelState) -> None: - """ - Toggle the state of the channel. - - Parameters: - channel_number: The channel to toggle. - state: The state to change the channel to. - """ - self._check_is_open() - self._validate_channel(channel_number) - # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number], state.value) + # For the MPC320 the channel number does not matter here. The device has one LED that flashes irrespective + # of the provided channel number. + self._apt_protocol.write_param_command(AptMessageId.MOD_IDENTIFY.value, 0x01) @rpc_method def enable_channels(self, channel_numbers: List[int]) -> None: @@ -253,7 +238,7 @@ def disconnect_hardware(self) -> None: """ Disconnect hardware from USB bus. """ - _logger.info("[%s] Disconnecting instrument from USB hub", self._name) + _logger.info("[%s] Disconnecting instrument from USB bus", self._name) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.HW_DISCONNECT.value) From d155c47e11843d00ca789d35f5a4cb75df83bef8 Mon Sep 17 00:00:00 2001 From: Badge Bot Date: Wed, 27 Mar 2024 16:30:48 +0000 Subject: [PATCH 15/37] commit badges --- .github/badges/mypy.svg | 12 ++++++------ .github/badges/pylint.svg | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/badges/mypy.svg b/.github/badges/mypy.svg index 0411eb05..6e625d92 100644 --- a/.github/badges/mypy.svg +++ b/.github/badges/mypy.svg @@ -1,23 +1,23 @@ - + - + - - + + mypy mypy - fail - fail + pass + pass diff --git a/.github/badges/pylint.svg b/.github/badges/pylint.svg index 3c83aabf..1679e7d7 100644 --- a/.github/badges/pylint.svg +++ b/.github/badges/pylint.svg @@ -1,23 +1,23 @@ - + - + - - + + pylint pylint - 9.2 - 9.2 + 9.19 + 9.19 From 43b65e5cd6b66c7a65936cf3d9ce04699627db20 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Thu, 28 Mar 2024 16:35:06 +0100 Subject: [PATCH 16/37] added method to jog --- qmi/instruments/thorlabs/apt_protocol.py | 9 ++++++++- qmi/instruments/thorlabs/mpc320.py | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index 08af343c..860fa993 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -75,17 +75,24 @@ class AptMessageId(Enum): MOT_SET_EEPROMPARAMS = 0x04B9 MOT_REQ_USTATUSUPDATE = 0x0490 MOT_GET_USTATUSUPDATE = 0x0491 + MOT_MOVE_JOG = 0x046A POL_SET_PARAMS = 0x0530 POL_REQ_PARAMS = 0x0531 POL_GET_PARAMS = 0x0532 class AptChannelState(Enum): - """Possible channel states""" + """Channel state""" ENABLE = 0x01 DISABLE = 0x02 +class AptChannelJogDirection(Enum): + """Jog direction""" + + FORWARD = 0x01 + BACKWARD = 0x02 + class AptMessageHeaderWithParams(LittleEndianStructure): """ diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 8f968842..f66bbe63 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -18,7 +18,7 @@ MOT_SET_EEPROMPARAMS, POL_GET_SET_PARAMS, ) -from qmi.instruments.thorlabs.apt_protocol import AptChannelState, AptMessageId, AptProtocol +from qmi.instruments.thorlabs.apt_protocol import AptChannelJogDirection, AptChannelState, AptMessageId, AptProtocol # Global variable holding the logger for this module. _logger = logging.getLogger(__name__) @@ -238,6 +238,7 @@ def disconnect_hardware(self) -> None: """ Disconnect hardware from USB bus. """ + # TODO: this does nothing _logger.info("[%s] Disconnecting instrument from USB bus", self._name) self._check_is_open() # Send message. @@ -383,6 +384,22 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: return Thorlabs_MPC320_Status(channel=channel_number, position=resp.position * self.ENCODER_CONVERSION_UNIT, velocity=resp.velocity, motor_current=resp.motor_current) + @rpc_method + def jog(self, channel_number: int, direction: AptChannelJogDirection= AptChannelJogDirection.FORWARD) -> None: + """ + Move a channel specified by its jog step. + + Parameters: + channel_number: The channel to job. + direction: The direction to job. This can either be forward or backward. Default is forward. + """ + # TODO: check for jog completion + _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) + self._check_is_open() + self._validate_channel(channel_number) + # Send request message. + self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_JOG.value, Thorlabs_MPC320_ChannelMap[channel_number], direction.value) + @rpc_method def set_polarisation_parameters( self, From 2008f264e33e5d695cc5ec5c293ce6ae7d4c06c2 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 5 Apr 2024 18:21:11 +0200 Subject: [PATCH 17/37] bugfix channel mapping --- qmi/instruments/thorlabs/mpc320.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index f66bbe63..cf0e2f2b 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -197,7 +197,7 @@ def enable_channels(self, channel_numbers: List[int]) -> None: # Make hexadecimal value for channels channels_to_enable = 0x00 for channel_number in channel_numbers: - channels_to_enable ^= channel_number + channels_to_enable ^= Thorlabs_MPC320_ChannelMap[channel_number] # Send message. self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, channels_to_enable, AptChannelState.ENABLE.value) From 7e4de7c7ee96fe1b482b19a296c45a22dffb7451 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 12 Apr 2024 13:27:10 +0200 Subject: [PATCH 18/37] accept anything except 0x01 as DISABLE state --- qmi/instruments/thorlabs/apt_protocol.py | 7 +- qmi/instruments/thorlabs/mpc320.py | 114 +++++++++++++++-------- 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index 860fa993..62fde6ba 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -16,7 +16,9 @@ apt_dword = c_uint32 apt_long = c_int32 apt_char = c_char -apt_byte = c_uint8 # this format specifier is not defined in the APT protocol manual but is helpful for packets that are divided into single bytes +# this format specifier is not defined in the APT protocol manual but is helpful for packets that are divided into +# single bytes +apt_byte = c_uint8 class AptStatusBits(Enum): @@ -87,6 +89,7 @@ class AptChannelState(Enum): ENABLE = 0x01 DISABLE = 0x02 + class AptChannelJogDirection(Enum): """Jog direction""" @@ -132,8 +135,10 @@ class AptMessage(LittleEndianStructure): HEADER_ONLY: bool = False _pack_ = True + T = TypeVar("T", bound=AptMessage) + class AptProtocol: """ Implement the Thorlabs APT protocol primitives. diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index cf0e2f2b..7e1ecc48 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -23,6 +23,7 @@ # Global variable holding the logger for this module. _logger = logging.getLogger(__name__) + @dataclass class Thorlabs_MPC320_Status: """ @@ -34,11 +35,13 @@ class Thorlabs_MPC320_Status: velocity: Velocity in controller units. motor_current: Current of motor in mA """ + channel: int position: float velocity: int motor_current: int + @dataclass class Thorlabs_MPC320_PolarisationParameters: """ @@ -51,17 +54,15 @@ class Thorlabs_MPC320_PolarisationParameters: jog_step2: The position to move paddel/channel 2 by for a jog step in degrees. jog_step3: The position to move paddel/channel 3 by for a jog step in degrees. """ + velocity: float home_position: float jog_step1: float jog_step2: float jog_step3: float -Thorlabs_MPC320_ChannelMap: Dict[int, int] = { - 1: 0x01, - 2: 0x02, - 3: 0x04 -} + +Thorlabs_MPC320_ChannelMap: Dict[int, int] = {1: 0x01, 2: 0x02, 3: 0x04} class Thorlabs_MPC320(QMI_Instrument): @@ -73,7 +74,7 @@ class Thorlabs_MPC320(QMI_Instrument): # the maximum range for a paddle is 170 degrees # the value returned by the encoder is 1370 for 170 degrees - ENCODER_CONVERSION_UNIT = 170/1370 + ENCODER_CONVERSION_UNIT = 170 / 1370 MIN_POSITION_DEGREES = 0 MAX_POSITION_DEGREES = 170 @@ -97,7 +98,8 @@ def __init__(self, context: QMI_Context, name: str, transport: str) -> None: def _validate_position(self, pos: float) -> None: """ - Validate the position. Any position for the MPC320 needs to be in the range 0 to 170 degrees, or 0 to 1370 in encoder counts. + Validate the position. Any position for the MPC320 needs to be in the range 0 to 170 degrees, + or 0 to 1370 in encoder counts. Parameters: pos: Position to validate in degrees. @@ -106,11 +108,15 @@ def _validate_position(self, pos: float) -> None: an instance of QMI_InstrumentException if the position is invalid. """ if not self.MIN_POSITION_DEGREES <= pos <= self.MAX_POSITION_DEGREES: - raise QMI_InstrumentException(f"Given position {pos} is outside the valid range [{self.MIN_POSITION_DEGREES}, {self.MAX_POSITION_DEGREES}]") - + raise QMI_InstrumentException( + f"Given position {pos} is outside the valid range \ + [{self.MIN_POSITION_DEGREES}, {self.MAX_POSITION_DEGREES}]" + ) + def _validate_velocity(self, vel: float) -> None: """ - Validate the velocity. Any velocity for the MPC320 needs to be in the range 40 to 400 degrees/s, or 10 to 100% of 400 degrees/s. + Validate the velocity. Any velocity for the MPC320 needs to be in the range 40 to 400 degrees/s, + or 10 to 100% of 400 degrees/s. Parameters: vel: Velocity to validate in percentage. @@ -119,8 +125,11 @@ def _validate_velocity(self, vel: float) -> None: an instance of QMI_InstrumentException if the velocity is invalid. """ if not self.MIN_VELOCITY_PERC <= vel <= self.MAX_VELOCITY_PERC: - raise QMI_InstrumentException(f"Given relative velocity {vel} is outside the valid range [{self.MIN_VELOCITY_PERC}%, {self.MAX_VELOCITY_PERC}%]") - + raise QMI_InstrumentException( + f"Given relative velocity {vel} is outside the valid range \ + [{self.MIN_VELOCITY_PERC}%, {self.MAX_VELOCITY_PERC}%]" + ) + def _validate_channel(self, channel_number: int) -> None: """ Validate the channel number. The MPC320 has 3 channels. @@ -133,7 +142,10 @@ def _validate_channel(self, channel_number: int) -> None: """ if channel_number not in [1, 2, 3]: - raise QMI_InstrumentException(f"Given channel {channel_number} is not in the valid range [{self.MIN_CHANNEL_NUMBER}, {self.MAX_CHANNEL_NUMBER}]") + raise QMI_InstrumentException( + f"Given channel {channel_number} is not in the valid range \ + [{self.MIN_CHANNEL_NUMBER}, {self.MAX_CHANNEL_NUMBER}]" + ) @rpc_method def open(self) -> None: @@ -179,12 +191,14 @@ def identify(self) -> None: @rpc_method def enable_channels(self, channel_numbers: List[int]) -> None: """ - Enable the channel(s). Note that this method will disable any channel that is not provided as an argument. For example, if - you enable channel 1, then 2 and 3 will be disabled. If you have previously enabled a channel(s) and fail - to include it/them again in this call, that channel(s) will be disabled. For example, if you run the following: + Enable the channel(s). Note that this method will disable any channel that is not provided as an argument. For + example, if you enable channel 1, then 2 and 3 will be disabled. If you have previously enabled a channel(s) + and fail to include it/them again in this call, that channel(s) will be disabled. For example, if you run the + following: self.enable_channel([1]) self.enable_channel([2]) - only channel 2 will be enabled and 1 and 3 will be disabled. The correct way to call this method in this case is + only channel 2 will be enabled and 1 and 3 will be disabled. + The correct way to call this method in this case is self.enable_channel([1,2]) Parameters: @@ -199,7 +213,9 @@ def enable_channels(self, channel_numbers: List[int]) -> None: for channel_number in channel_numbers: channels_to_enable ^= Thorlabs_MPC320_ChannelMap[channel_number] # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, channels_to_enable, AptChannelState.ENABLE.value) + self._apt_protocol.write_param_command( + AptMessageId.MOD_SET_CHANENABLESTATE.value, channels_to_enable, AptChannelState.ENABLE.value + ) @rpc_method def disable_all_channels(self) -> None: @@ -208,7 +224,9 @@ def disable_all_channels(self) -> None: """ _logger.info("[%s] Disabling channels", self._name) self._check_is_open() - self._apt_protocol.write_param_command(AptMessageId.MOD_SET_CHANENABLESTATE.value, 0x00, AptChannelState.ENABLE.value) + self._apt_protocol.write_param_command( + AptMessageId.MOD_SET_CHANENABLESTATE.value, 0x00, AptChannelState.ENABLE.value + ) @rpc_method def get_channel_state(self, channel_number: int) -> AptChannelState: @@ -225,13 +243,15 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: self._validate_channel(channel_number) self._check_is_open() # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOD_REQ_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number]) + self._apt_protocol.write_param_command( + AptMessageId.MOD_REQ_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number] + ) # Get response resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) - # For the MPC320 the state 0x00 is also a valid channel state. It is also the disable state - if resp.enable_state == 0x00: - return AptChannelState.DISABLE - return AptChannelState(resp.enable_state) + # For the MPC320 the state 0x01 is the ENABLE state and anything else is DISABLE + if resp.enable_state == 0x01: + return AptChannelState.ENABLE + return AptChannelState.DISABLE @rpc_method def disconnect_hardware(self) -> None: @@ -276,7 +296,9 @@ def home_channel(self, channel_number: int) -> None: self._validate_channel(channel_number) self._check_is_open() # Send message. - self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_HOME.value, Thorlabs_MPC320_ChannelMap[channel_number]) + self._apt_protocol.write_param_command( + AptMessageId.MOT_MOVE_HOME.value, Thorlabs_MPC320_ChannelMap[channel_number] + ) @rpc_method def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: @@ -299,13 +321,13 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) # Check if the channel number in the response is equal to the one that was asked for. return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] - + @rpc_method def move_absolute(self, channel_number: int, position: float) -> None: """ - Move a channel to the specified position. The specified position is in degeres. A conversion is done to convert this - into encoder counts. This means that there may be a slight mismatch in the specified position and the actual position. - You may use the get_status_update method to get the actual position. + Move a channel to the specified position. The specified position is in degeres. A conversion is done to convert + this into encoder counts. This means that there may be a slight mismatch in the specified position and the + actual position. You may use the get_status_update method to get the actual position. Parameters: channel_number: The channel to address. @@ -318,7 +340,9 @@ def move_absolute(self, channel_number: int, position: float) -> None: # Convert position in degrees to encoder counts. encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) # Make data packet. - data_packet = MOT_MOVE_ABSOLUTE(chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], absolute_distance=encoder_position) + data_packet = MOT_MOVE_ABSOLUTE( + chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], absolute_distance=encoder_position + ) # Send message. self._apt_protocol.write_data_command(AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet) @@ -378,14 +402,20 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: self._check_is_open() self._validate_channel(channel_number) # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOT_REQ_USTATUSUPDATE.value, Thorlabs_MPC320_ChannelMap[channel_number]) + self._apt_protocol.write_param_command( + AptMessageId.MOT_REQ_USTATUSUPDATE.value, Thorlabs_MPC320_ChannelMap[channel_number] + ) # Get response resp = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) - return Thorlabs_MPC320_Status(channel=channel_number, position=resp.position * self.ENCODER_CONVERSION_UNIT, - velocity=resp.velocity, motor_current=resp.motor_current) + return Thorlabs_MPC320_Status( + channel=channel_number, + position=resp.position * self.ENCODER_CONVERSION_UNIT, + velocity=resp.velocity, + motor_current=resp.motor_current, + ) @rpc_method - def jog(self, channel_number: int, direction: AptChannelJogDirection= AptChannelJogDirection.FORWARD) -> None: + def jog(self, channel_number: int, direction: AptChannelJogDirection = AptChannelJogDirection.FORWARD) -> None: """ Move a channel specified by its jog step. @@ -398,7 +428,9 @@ def jog(self, channel_number: int, direction: AptChannelJogDirection= AptChanne self._check_is_open() self._validate_channel(channel_number) # Send request message. - self._apt_protocol.write_param_command(AptMessageId.MOT_MOVE_JOG.value, Thorlabs_MPC320_ChannelMap[channel_number], direction.value) + self._apt_protocol.write_param_command( + AptMessageId.MOT_MOVE_JOG.value, Thorlabs_MPC320_ChannelMap[channel_number], direction.value + ) @rpc_method def set_polarisation_parameters( @@ -449,8 +481,10 @@ def get_polarisation_parameters(self) -> Thorlabs_MPC320_PolarisationParameters: self._apt_protocol.write_param_command(AptMessageId.POL_REQ_PARAMS.value) # Get response. params = self._apt_protocol.ask(POL_GET_SET_PARAMS) - return Thorlabs_MPC320_PolarisationParameters(velocity=params.velocity, - home_position=params.home_position * self.ENCODER_CONVERSION_UNIT, - jog_step1=params.jog_step1 * self.ENCODER_CONVERSION_UNIT, - jog_step2=params.jog_step2 * self.ENCODER_CONVERSION_UNIT, - jog_step3=params.jog_step3 * self.ENCODER_CONVERSION_UNIT) + return Thorlabs_MPC320_PolarisationParameters( + velocity=params.velocity, + home_position=params.home_position * self.ENCODER_CONVERSION_UNIT, + jog_step1=params.jog_step1 * self.ENCODER_CONVERSION_UNIT, + jog_step2=params.jog_step2 * self.ENCODER_CONVERSION_UNIT, + jog_step3=params.jog_step3 * self.ENCODER_CONVERSION_UNIT, + ) From fb20f78b5ebda2c8a5c0984bf80d88c385b65a40 Mon Sep 17 00:00:00 2001 From: Badge Bot Date: Fri, 12 Apr 2024 11:32:54 +0000 Subject: [PATCH 19/37] commit badges --- .github/badges/pylint.svg | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/badges/pylint.svg b/.github/badges/pylint.svg index 1679e7d7..3c83aabf 100644 --- a/.github/badges/pylint.svg +++ b/.github/badges/pylint.svg @@ -1,23 +1,23 @@ - + - + - - + + pylint pylint - 9.19 - 9.19 + 9.2 + 9.2 From 067c471e14ab8c37bab4a3235ab265ec5dd18e0c Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 30 Apr 2024 14:56:25 +0200 Subject: [PATCH 20/37] move completed and homed commands have retries --- qmi/instruments/thorlabs/apt_packets.py | 1 + qmi/instruments/thorlabs/apt_protocol.py | 30 +++++++-- qmi/instruments/thorlabs/mpc320.py | 79 ++++++++++++++++-------- 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index ac3025dc..51b7a292 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -89,6 +89,7 @@ class MOT_MOVE_HOMED(AptMessage): ("source", apt_byte), ] + class MOT_MOVE_ABSOLUTE(AptMessage): """ Data packet structure for a MOT_SMOVE_ABSOLUTE command. diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index 62fde6ba..5f009376 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -3,7 +3,16 @@ here https://www.thorlabs.com/Software/Motion%20Control/APT_Communications_Protocol.pdf """ -from ctypes import LittleEndianStructure, c_uint8, c_uint16, c_int16, c_uint32, c_int32, c_char, sizeof +from ctypes import ( + LittleEndianStructure, + c_uint8, + c_uint16, + c_int16, + c_uint32, + c_int32, + c_char, + sizeof, +) from enum import Enum from typing import List, Optional, Tuple, Type, TypeVar @@ -67,7 +76,6 @@ class AptMessageId(Enum): MOD_SET_CHANENABLESTATE = 0x0210 MOD_REQ_CHANENABLESTATE = 0x0211 MOD_GET_CHANENABLESTATE = 0x0212 - HW_DISCONNECT = 0x0002 HW_START_UPDATEMSGS = 0x0011 HW_STOP_UPDATEMSGS = 0x0012 MOT_MOVE_HOME = 0x0443 @@ -169,7 +177,12 @@ def __init__( self._apt_device_address = apt_device_address self._host_address = host_address - def write_param_command(self, message_id: int, param1: Optional[int] = None, param2: Optional[int] = None) -> None: + def write_param_command( + self, + message_id: int, + param1: Optional[int] = None, + param2: Optional[int] = None, + ) -> None: """ Send an APT protocol command that is a header (i.e. 6 bytes) with params. @@ -180,7 +193,11 @@ def write_param_command(self, message_id: int, param1: Optional[int] = None, par """ # Make the command. msg = AptMessageHeaderWithParams( - message_id, param1 or 0x00, param2 or 0x00, self._apt_device_address, self._host_address + message_id, + param1 or 0x00, + param2 or 0x00, + self._apt_device_address, + self._host_address, ) # Send command. self._transport.write(bytearray(msg)) @@ -220,12 +237,13 @@ def ask(self, data_type: Type[T], timeout: Optional[float] = None) -> T: header = AptMessageHeaderForData.from_buffer_copy(header_bytes) data_length = header.date_length + # Read the data packet that follows the header. + data_bytes = self._transport.read(nbytes=data_length, timeout=timeout) + # Check that the received message ID is the ID that is expected. if data_type.MESSAGE_ID != header.message_id: raise QMI_InstrumentException( f"Expected message with ID {data_type.MESSAGE_ID}, but received {header.message_id}" ) - # Read the data packet that follows the header. - data_bytes = self._transport.read(nbytes=data_length, timeout=timeout) return data_type.from_buffer_copy(data_bytes) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 7e1ecc48..223a65e2 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -18,7 +18,12 @@ MOT_SET_EEPROMPARAMS, POL_GET_SET_PARAMS, ) -from qmi.instruments.thorlabs.apt_protocol import AptChannelJogDirection, AptChannelState, AptMessageId, AptProtocol +from qmi.instruments.thorlabs.apt_protocol import ( + AptChannelJogDirection, + AptChannelState, + AptMessageId, + AptProtocol, +) # Global variable holding the logger for this module. _logger = logging.getLogger(__name__) @@ -214,7 +219,9 @@ def enable_channels(self, channel_numbers: List[int]) -> None: channels_to_enable ^= Thorlabs_MPC320_ChannelMap[channel_number] # Send message. self._apt_protocol.write_param_command( - AptMessageId.MOD_SET_CHANENABLESTATE.value, channels_to_enable, AptChannelState.ENABLE.value + AptMessageId.MOD_SET_CHANENABLESTATE.value, + channels_to_enable, + AptChannelState.ENABLE.value, ) @rpc_method @@ -225,7 +232,9 @@ def disable_all_channels(self) -> None: _logger.info("[%s] Disabling channels", self._name) self._check_is_open() self._apt_protocol.write_param_command( - AptMessageId.MOD_SET_CHANENABLESTATE.value, 0x00, AptChannelState.ENABLE.value + AptMessageId.MOD_SET_CHANENABLESTATE.value, + 0x00, + AptChannelState.ENABLE.value, ) @rpc_method @@ -244,7 +253,8 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: self._check_is_open() # Send request message. self._apt_protocol.write_param_command( - AptMessageId.MOD_REQ_CHANENABLESTATE.value, Thorlabs_MPC320_ChannelMap[channel_number] + AptMessageId.MOD_REQ_CHANENABLESTATE.value, + Thorlabs_MPC320_ChannelMap[channel_number], ) # Get response resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) @@ -253,17 +263,6 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: return AptChannelState.ENABLE return AptChannelState.DISABLE - @rpc_method - def disconnect_hardware(self) -> None: - """ - Disconnect hardware from USB bus. - """ - # TODO: this does nothing - _logger.info("[%s] Disconnecting instrument from USB bus", self._name) - self._check_is_open() - # Send message. - self._apt_protocol.write_param_command(AptMessageId.HW_DISCONNECT.value) - @rpc_method def start_auto_status_update(self) -> None: """ @@ -317,8 +316,17 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS _logger.info("[%s] Checking if channel %d is homed", self._name, channel_number) self._validate_channel(channel_number) self._check_is_open() - # Get response. - resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) + # This command needs a workaround. Instead of returning the MOT_MOVE_HOMED message, the instrument may return + # its current state first. So subsequent messages may have to be read. + num_reads = 5 + while num_reads > 0: + try: + # Get response. + resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) + except QMI_InstrumentException: + num_reads -= 1 + continue + # Check if the channel number in the response is equal to the one that was asked for. return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] @@ -341,7 +349,8 @@ def move_absolute(self, channel_number: int, position: float) -> None: encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) # Make data packet. data_packet = MOT_MOVE_ABSOLUTE( - chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], absolute_distance=encoder_position + chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], + absolute_distance=encoder_position, ) # Send message. self._apt_protocol.write_data_command(AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet) @@ -360,12 +369,24 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON Returns: True if the move for the channel was completed. """ - # TODO: add status data packet - _logger.info("[%s] Checking if channel %d has completed its move", self._name, channel_number) + _logger.info( + "[%s] Checking if channel %d has completed its move", + self._name, + channel_number, + ) self._validate_channel(channel_number) self._check_is_open() - # Get response - resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) + # This command needs a workaround. Instead of returning the MOT_MOVE_HOMED message, the instrument may return + # its current state first. So subsequent messages may have to be read. + num_reads = 5 + while num_reads > 0: + try: + # Get response. + resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) + except QMI_InstrumentException: + num_reads -= 1 + continue + return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] @rpc_method @@ -403,7 +424,8 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: self._validate_channel(channel_number) # Send request message. self._apt_protocol.write_param_command( - AptMessageId.MOT_REQ_USTATUSUPDATE.value, Thorlabs_MPC320_ChannelMap[channel_number] + AptMessageId.MOT_REQ_USTATUSUPDATE.value, + Thorlabs_MPC320_ChannelMap[channel_number], ) # Get response resp = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) @@ -415,7 +437,11 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: ) @rpc_method - def jog(self, channel_number: int, direction: AptChannelJogDirection = AptChannelJogDirection.FORWARD) -> None: + def jog( + self, + channel_number: int, + direction: AptChannelJogDirection = AptChannelJogDirection.FORWARD, + ) -> None: """ Move a channel specified by its jog step. @@ -423,13 +449,14 @@ def jog(self, channel_number: int, direction: AptChannelJogDirection = AptChanne channel_number: The channel to job. direction: The direction to job. This can either be forward or backward. Default is forward. """ - # TODO: check for jog completion _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) self._check_is_open() self._validate_channel(channel_number) # Send request message. self._apt_protocol.write_param_command( - AptMessageId.MOT_MOVE_JOG.value, Thorlabs_MPC320_ChannelMap[channel_number], direction.value + AptMessageId.MOT_MOVE_JOG.value, + Thorlabs_MPC320_ChannelMap[channel_number], + direction.value, ) @rpc_method From c25195202ec8f038085ae4b02c89b6d7861146f5 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Wed, 10 Jul 2024 14:35:23 +0200 Subject: [PATCH 21/37] homed and move completed fixes --- qmi/instruments/thorlabs/mpc320.py | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 223a65e2..4505c688 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -316,16 +316,11 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS _logger.info("[%s] Checking if channel %d is homed", self._name, channel_number) self._validate_channel(channel_number) self._check_is_open() - # This command needs a workaround. Instead of returning the MOT_MOVE_HOMED message, the instrument may return - # its current state first. So subsequent messages may have to be read. - num_reads = 5 - while num_reads > 0: - try: - # Get response. - resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) - except QMI_InstrumentException: - num_reads -= 1 - continue + # This command needs a workaround. Instead of returning the MOT_MOVE_HOMED message, the instrument returns + # its current state first, so read that to discard the buffer + _ = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE, timeout) + # then read the actual response we need + resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) # Check if the channel number in the response is equal to the one that was asked for. return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] @@ -376,16 +371,11 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON ) self._validate_channel(channel_number) self._check_is_open() - # This command needs a workaround. Instead of returning the MOT_MOVE_HOMED message, the instrument may return - # its current state first. So subsequent messages may have to be read. - num_reads = 5 - while num_reads > 0: - try: - # Get response. - resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) - except QMI_InstrumentException: - num_reads -= 1 - continue + # This command needs a workaround. Instead of returning the MOT_MOVE_COMPLETED message, the instrument returns + # its current state first, so read that to discard the buffer + _ = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE, timeout) + # then read the actual response we need + resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] From 43acbc7894c5c404e1edc2749f2b4a21f3bc3d34 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 12 Jul 2024 23:00:55 +0200 Subject: [PATCH 22/37] first unittest and formatting --- qmi/instruments/thorlabs/__init__.py | 3 ++ qmi/instruments/thorlabs/apt_packets.py | 5 +- qmi/instruments/thorlabs/apt_protocol.py | 17 +++++-- qmi/instruments/thorlabs/mpc320.py | 54 +++++++++++++++----- tests/instruments/thorlabs/test_mpc320.py | 62 +++++++++++++++++++++++ 5 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 tests/instruments/thorlabs/test_mpc320.py diff --git a/qmi/instruments/thorlabs/__init__.py b/qmi/instruments/thorlabs/__init__.py index 81e896dc..7c1aa7b2 100644 --- a/qmi/instruments/thorlabs/__init__.py +++ b/qmi/instruments/thorlabs/__init__.py @@ -7,8 +7,10 @@ - PM100D power meter - TSP01, TSP01B environmental sensors. """ + from qmi.instruments.thorlabs.pm100d import SensorInfo from qmi.instruments.thorlabs.tc200 import Tc200Status + # Alternative, QMI naming convention approved names from qmi.instruments.thorlabs.k10cr1 import Thorlabs_K10CR1 as Thorlabs_K10Cr1 from qmi.instruments.thorlabs.mff10x import Thorlabs_MFF10X as Thorlabs_Mff10X @@ -19,3 +21,4 @@ from qmi.instruments.thorlabs.tc200 import Thorlabs_TC200 as Thorlabs_Tc200 from qmi.instruments.thorlabs.tsp01 import Thorlabs_TSP01 as Thorlabs_Tsp01 from qmi.instruments.thorlabs.tsp01b import Thorlabs_TSP01B as Thorlabs_Tsp01b +from qmi.instruments.thorlabs.mpc320 import Thorlabs_Mpc320 diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index 51b7a292..6004e380 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -100,7 +100,10 @@ class MOT_MOVE_ABSOLUTE(AptMessage): """ MESSAGE_ID = AptMessageId.MOT_MOVE_ABSOLUTE.value - _fields_: List[Tuple[str, type]] = [("chan_ident", apt_word), ("absolute_distance", apt_long)] + _fields_: List[Tuple[str, type]] = [ + ("chan_ident", apt_word), + ("absolute_distance", apt_long), + ] class MOT_MOVE_COMPLETED(AptMessage): diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index 5f009376..e42a4b76 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -60,11 +60,15 @@ class AptStatusBits(Enum): P_MOT_SB_OVERLOAD = 0x01000000 # some form of motor overload P_MOT_SB_ENCODERFAULT = 0x02000000 # encoder fault P_MOT_SB_OVERCURRENT = 0x04000000 # motor exceeded continuous current limit - P_MOT_SB_BUSCURRENTFAULT = 0x08000000 # excessive current being drawn from motor power supply + P_MOT_SB_BUSCURRENTFAULT = ( + 0x08000000 # excessive current being drawn from motor power supply + ) P_MOT_SB_POWEROK = 0x10000000 # controller power supplies operating normally P_MOT_SB_ACTIVE = 0x20000000 # controller executing motion commend P_MOT_SB_ERROR = 0x40000000 # indicates an error condition - P_MOT_SB_ENABLED = 0x80000000 # motor output enabled, with controller maintaining position + P_MOT_SB_ENABLED = ( + 0x80000000 # motor output enabled, with controller maintaining position + ) class AptMessageId(Enum): @@ -201,6 +205,7 @@ def write_param_command( ) # Send command. self._transport.write(bytearray(msg)) + print(bytearray(msg)) def write_data_command(self, message_id: int, data: T) -> None: """ @@ -210,7 +215,9 @@ def write_data_command(self, message_id: int, data: T) -> None: data_length = sizeof(data) # Make the header. - msg = AptMessageHeaderForData(message_id, data_length, self._apt_device_address | 0x80, self._host_address) + msg = AptMessageHeaderForData( + message_id, data_length, self._apt_device_address | 0x80, self._host_address + ) # Send the header and data packet. self._transport.write(bytearray(msg) + bytearray(data)) @@ -231,7 +238,9 @@ def ask(self, data_type: Type[T], timeout: Optional[float] = None) -> T: timeout = self._timeout # Read the header first. - header_bytes = self._transport.read(nbytes=self.HEADER_SIZE_BYTES, timeout=timeout) + header_bytes = self._transport.read( + nbytes=self.HEADER_SIZE_BYTES, timeout=timeout + ) if data_type.HEADER_ONLY: return data_type.from_buffer_copy(header_bytes) header = AptMessageHeaderForData.from_buffer_copy(header_bytes) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 4505c688..3d26cf68 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -70,7 +70,7 @@ class Thorlabs_MPC320_PolarisationParameters: Thorlabs_MPC320_ChannelMap: Dict[int, int] = {1: 0x01, 2: 0x02, 3: 0x04} -class Thorlabs_MPC320(QMI_Instrument): +class Thorlabs_Mpc320(QMI_Instrument): """ Driver for a Thorlabs MPC320 motorised fibre polarisation controller. """ @@ -98,8 +98,12 @@ def __init__(self, context: QMI_Context, name: str, transport: str) -> None: transport: QMI transport descriptor to connect to the instrument. """ super().__init__(context, name) - self._transport = create_transport(transport, default_attributes={"baudrate": 115200, "rtscts": True}) - self._apt_protocol = AptProtocol(self._transport, default_timeout=self.DEFAULT_RESPONSE_TIMEOUT) + self._transport = create_transport( + transport, default_attributes={"baudrate": 115200, "rtscts": True} + ) + self._apt_protocol = AptProtocol( + self._transport, default_timeout=self.DEFAULT_RESPONSE_TIMEOUT + ) def _validate_position(self, pos: float) -> None: """ @@ -179,7 +183,9 @@ def get_idn(self) -> QMI_InstrumentIdentification: self._apt_protocol.write_param_command(AptMessageId.HW_REQ_INFO.value) # Get response resp = self._apt_protocol.ask(HW_GET_INFO) - return QMI_InstrumentIdentification("Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version) + return QMI_InstrumentIdentification( + "Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version + ) @rpc_method def identify(self) -> None: @@ -268,7 +274,9 @@ def start_auto_status_update(self) -> None: """ Start automatic status updates from device. """ - _logger.info("[%s] Starting automatic status updates from instrument", self._name) + _logger.info( + "[%s] Starting automatic status updates from instrument", self._name + ) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.HW_START_UPDATEMSGS.value) @@ -278,7 +286,9 @@ def stop_auto_status_update(self) -> None: """ Stop automatic status updates from device. """ - _logger.info("[%s] Stopping automatic status updates from instrument", self._name) + _logger.info( + "[%s] Stopping automatic status updates from instrument", self._name + ) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.HW_STOP_UPDATEMSGS.value) @@ -300,7 +310,9 @@ def home_channel(self, channel_number: int) -> None: ) @rpc_method - def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: + def is_channel_homed( + self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT + ) -> bool: """ Check if a given channel is homed. This command should only be run after the method `home_channel`. Otherwise you will read bytes from other commands using this method. @@ -348,10 +360,14 @@ def move_absolute(self, channel_number: int, position: float) -> None: absolute_distance=encoder_position, ) # Send message. - self._apt_protocol.write_data_command(AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet) + self._apt_protocol.write_data_command( + AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet + ) @rpc_method - def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: + def is_move_completed( + self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT + ) -> bool: """ Check if a given channel has completed its move. This command should only be run after a relative or absolute move command. Otherwise you will read bytes from other commands. @@ -393,9 +409,13 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: self._check_is_open() self._validate_channel(channel_number) # Make data packet. - data_packet = MOT_SET_EEPROMPARAMS(chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], msg_id=message_id) + data_packet = MOT_SET_EEPROMPARAMS( + chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], msg_id=message_id + ) # Send message. - self._apt_protocol.write_data_command(AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet) + self._apt_protocol.write_data_command( + AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet + ) @rpc_method def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: @@ -409,7 +429,9 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: Returns: An instance of Thorlabs_MPC320_Status. """ - _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) + _logger.info( + "[%s] Getting position counter of channel %d", self._name, channel_number + ) self._check_is_open() self._validate_channel(channel_number) # Send request message. @@ -439,7 +461,9 @@ def jog( channel_number: The channel to job. direction: The direction to job. This can either be forward or backward. Default is forward. """ - _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) + _logger.info( + "[%s] Getting position counter of channel %d", self._name, channel_number + ) self._check_is_open() self._validate_channel(channel_number) # Send request message. @@ -485,7 +509,9 @@ def set_polarisation_parameters( jog_step3=round(jog_step3 / self.ENCODER_CONVERSION_UNIT), ) # Send message. - self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) + self._apt_protocol.write_data_command( + AptMessageId.POL_SET_PARAMS.value, data_packet + ) @rpc_method def get_polarisation_parameters(self) -> Thorlabs_MPC320_PolarisationParameters: diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py new file mode 100644 index 00000000..f8d7d5e0 --- /dev/null +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -0,0 +1,62 @@ +import random +import unittest +import unittest.mock +import qmi +from qmi.core.transport import QMI_SerialTransport +from qmi.instruments.thorlabs import Thorlabs_Mpc320 + + +class TestThorlabsMPC320(unittest.TestCase): + def setUp(self): + # Patch QMI context and make instrument + self._ctx_qmi_id = f"test-tasks-{random.randint(0, 100)}" + qmi.start(self._ctx_qmi_id) + self._transport_mock = unittest.mock.MagicMock(spec=QMI_SerialTransport) + with unittest.mock.patch( + "qmi.instruments.thorlabs.mpc320.create_transport", + return_value=self._transport_mock, + ): + self._instr: Thorlabs_Mpc320 = qmi.make_instrument( + "test_mpc320", Thorlabs_Mpc320, "serial:transport_str" + ) + self._instr.open() + + def tearDown(self): + self._instr.close() + qmi.stop() + + def test_get_idn_returns_identification_info(self): + """Test get_idn method and returns identification info.""" + # Arrange + expected_idn = ["Thorlabs", b"MPC320 ", 94000009, 3735810] + # \x89\x53\x9a\x05 is 94000009 + # \x4d\x50\x43\x33\x32\x30\x0a is MPC320 + # x2c\x00 is Brushless DC controller card + # \x02\x01\x39\x00 is 3735810 + self._transport_mock.read.side_effect = [ + b"\x06\x00\x54\x00\x00\x81", + b"\x89\x53\x9a\x05\x4d\x50\x43\x33\x32\x30\x20\x00\x2c\x00\x02\x01\x39\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ] + + # Act + idn = self._instr.get_idn() + + # Assert + self.assertEqual(idn.vendor, expected_idn[0]) + self.assertEqual(idn.model, expected_idn[1]) + self.assertEqual(idn.serial, expected_idn[2]) + self.assertEqual(idn.version, expected_idn[3]) + + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x05\x00\x00\x00P\x01") + ) + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=84, timeout=1.0), + ] + ) + + +if __name__ == "__main__": + unittest.main() From 569d465d19c7575e995248c97a0191c7499b93d4 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Fri, 12 Jul 2024 23:41:20 +0200 Subject: [PATCH 23/37] added more tests --- qmi/instruments/thorlabs/__init__.py | 1 + qmi/instruments/thorlabs/apt_protocol.py | 1 - qmi/instruments/thorlabs/mpc320.py | 7 +- tests/instruments/thorlabs/test_mpc320.py | 180 +++++++++++++++++++++- 4 files changed, 186 insertions(+), 3 deletions(-) diff --git a/qmi/instruments/thorlabs/__init__.py b/qmi/instruments/thorlabs/__init__.py index 7c1aa7b2..b25f8470 100644 --- a/qmi/instruments/thorlabs/__init__.py +++ b/qmi/instruments/thorlabs/__init__.py @@ -10,6 +10,7 @@ from qmi.instruments.thorlabs.pm100d import SensorInfo from qmi.instruments.thorlabs.tc200 import Tc200Status +from qmi.instruments.thorlabs.apt_protocol import AptChannelState # Alternative, QMI naming convention approved names from qmi.instruments.thorlabs.k10cr1 import Thorlabs_K10CR1 as Thorlabs_K10Cr1 diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index e42a4b76..e54a28bf 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -205,7 +205,6 @@ def write_param_command( ) # Send command. self._transport.write(bytearray(msg)) - print(bytearray(msg)) def write_data_command(self, message_id: int, data: T) -> None: """ diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 3d26cf68..d7f95abe 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -297,6 +297,8 @@ def stop_auto_status_update(self) -> None: def home_channel(self, channel_number: int) -> None: """ Start the homing sequence for a given channel. + After running this command, you must clear the buffer by checking if the channel + was homed, using is_channel_homed() Paramters: channel_number: The channel to home. @@ -332,6 +334,8 @@ def is_channel_homed( # its current state first, so read that to discard the buffer _ = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE, timeout) # then read the actual response we need + # TODO: check what happens when the channel is not homed + # is no response received? resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) # Check if the channel number in the response is equal to the one that was asked for. @@ -343,12 +347,13 @@ def move_absolute(self, channel_number: int, position: float) -> None: Move a channel to the specified position. The specified position is in degeres. A conversion is done to convert this into encoder counts. This means that there may be a slight mismatch in the specified position and the actual position. You may use the get_status_update method to get the actual position. + After running this command, you must clear the buffer by checking if the channel + move was completed, using is_move_completed() Parameters: channel_number: The channel to address. position: Absolute position to move to in degrees. """ - # TODO: check for move completed command, otherwise the that message will stay in the buffer _logger.info("[%s] Moving channel %d", self._name, channel_number) self._validate_channel(channel_number) self._check_is_open() diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py index f8d7d5e0..840eed86 100644 --- a/tests/instruments/thorlabs/test_mpc320.py +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -2,8 +2,10 @@ import unittest import unittest.mock import qmi +from qmi.core.exceptions import QMI_InstrumentException from qmi.core.transport import QMI_SerialTransport from qmi.instruments.thorlabs import Thorlabs_Mpc320 +from qmi.instruments.thorlabs.apt_protocol import AptChannelState class TestThorlabsMPC320(unittest.TestCase): @@ -25,7 +27,7 @@ def tearDown(self): self._instr.close() qmi.stop() - def test_get_idn_returns_identification_info(self): + def test_get_idn_sends_command_and_returns_identification_info(self): """Test get_idn method and returns identification info.""" # Arrange expected_idn = ["Thorlabs", b"MPC320 ", 94000009, 3735810] @@ -57,6 +59,182 @@ def test_get_idn_returns_identification_info(self): ] ) + def test_identify_sends_command(self): + """Test identify method and send relevant command.""" + # Arrange + + # Act + self._instr.identify() + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x23\x02\x01\x00\x50\x01") + ) + + def test_enable_channels_enable_four_throws_exception(self): + """Test enable channel 4, throws exception.""" + # Arrange + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.enable_channels([4]) + + def test_enable_channels_enable_one_sends_command(self): + """Test enable channel 1, send command to enable channel 1.""" + # Arrange + + # Act + self._instr.enable_channels([1]) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x10\x02\x01\x01\x50\x01") + ) + + def test_enable_channels_enable_one_and_three_sends_command(self): + """Test enable channels 1 and 3, sends command to enable channels 1 and 3.""" + # Arrange + + # Act + self._instr.enable_channels([1, 3]) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x10\x02\x05\x01\x50\x01") + ) + + def test_disable_all_channels_sends_command(self): + """Test disable all channels, sends command to disable all channels.""" + # Arrange + + # Act + self._instr.disable_all_channels() + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x10\x02\x00\x01\x50\x01") + ) + + def test_get_channel_state_for_channel_four_throws_exception(self): + """Test get state of channel 4, throws exception.""" + # Arrange + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.get_channel_state(4) + + def test_get_channel_state_for_disabled_channel_1_sends_command_and_returns_disable_value( + self, + ): + """Test get state of disabled channel 1, sends command to get state.""" + # Arrange + self._transport_mock.read.return_value = b"\x12\x02\x01\x00\x00\x81" + + # Act + state = self._instr.get_channel_state(1) + + # Assert + self.assertEqual(state, AptChannelState.DISABLE) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x02\x01\x00\x50\x01") + ) + + def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabled_value( + self, + ): + """Test get state of enabled channel 1, sends command to get state.""" + # Arrange + self._transport_mock.read.return_value = b"\x12\x02\x01\x01\x00\x81" + + # Act + state = self._instr.get_channel_state(1) + + # Assert + self.assertEqual(state, AptChannelState.ENABLE) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x02\x01\x00\x50\x01") + ) + + def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disabled_value( + self, + ): + """Test get state of disabled channel 2, sends command to get state. + This test checks that if anything but 0x01 is received then the channel is disabled.""" + # Arrange + self._transport_mock.read.return_value = b"\x12\x02\x01\x03\x00\x81" + + # Act + state = self._instr.get_channel_state(1) + + # Assert + self.assertEqual(state, AptChannelState.DISABLE) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x02\x01\x00\x50\x01") + ) + + def test_start_auto_status_update_sends_command(self): + """Test start automatic status updates, sends command""" + # Arrange + + # Act + self._instr.start_auto_status_update() + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x00\x00\x00\x50\x01") + ) + + def test_stop_auto_status_update_sends_command(self): + """Test stop automatic status updates, sends command""" + # Arrange + + # Act + self._instr.stop_auto_status_update() + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x12\x00\x00\x00\x50\x01") + ) + + def test_home_channel_3_sends_command(self): + """Test home channel 3, sends command""" + # Arrange + + # Act + self._instr.home_channel(3) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x43\x04\x04\x00\x50\x01") + ) + + def test_is_channel_1_homed_when_channel_homed_sends_command_returns_status(self): + """Test is_channel_homed for channel 1 when channel is homed, send command and return homed value.""" + # Arrange + # first 2 binary strings are responses from the status update command + # last string is the homed response + self._transport_mock.read.side_effect = [ + b"\x91\x04\x0e\x00\x00\x81", + b"\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + b"\x44\x04\x01\x00\x00\x81", + ] + + # Act + state = self._instr.is_channel_homed(1) + + # Assert + self.assertTrue(state) + self._transport_mock.write.assert_not_called() + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=14, timeout=1.0), + unittest.mock.call(nbytes=6, timeout=1.0), + ] + ) + if __name__ == "__main__": unittest.main() From 3f60e11e86a61f91279ad8a08c0004f37f383d84 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Mon, 15 Jul 2024 15:58:34 +0200 Subject: [PATCH 24/37] more tests --- tests/instruments/thorlabs/test_mpc320.py | 98 ++++++++++++++--------- 1 file changed, 62 insertions(+), 36 deletions(-) diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py index 840eed86..ccc4818a 100644 --- a/tests/instruments/thorlabs/test_mpc320.py +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -18,9 +18,7 @@ def setUp(self): "qmi.instruments.thorlabs.mpc320.create_transport", return_value=self._transport_mock, ): - self._instr: Thorlabs_Mpc320 = qmi.make_instrument( - "test_mpc320", Thorlabs_Mpc320, "serial:transport_str" - ) + self._instr: Thorlabs_Mpc320 = qmi.make_instrument("test_mpc320", Thorlabs_Mpc320, "serial:transport_str") self._instr.open() def tearDown(self): @@ -49,9 +47,7 @@ def test_get_idn_sends_command_and_returns_identification_info(self): self.assertEqual(idn.serial, expected_idn[2]) self.assertEqual(idn.version, expected_idn[3]) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x05\x00\x00\x00P\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x05\x00\x00\x00P\x01")) self._transport_mock.read.assert_has_calls( [ unittest.mock.call(nbytes=6, timeout=1.0), @@ -67,9 +63,7 @@ def test_identify_sends_command(self): self._instr.identify() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x23\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x23\x02\x01\x00\x50\x01")) def test_enable_channels_enable_four_throws_exception(self): """Test enable channel 4, throws exception.""" @@ -88,9 +82,7 @@ def test_enable_channels_enable_one_sends_command(self): self._instr.enable_channels([1]) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x10\x02\x01\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x01\x01\x50\x01")) def test_enable_channels_enable_one_and_three_sends_command(self): """Test enable channels 1 and 3, sends command to enable channels 1 and 3.""" @@ -100,9 +92,7 @@ def test_enable_channels_enable_one_and_three_sends_command(self): self._instr.enable_channels([1, 3]) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x10\x02\x05\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x05\x01\x50\x01")) def test_disable_all_channels_sends_command(self): """Test disable all channels, sends command to disable all channels.""" @@ -112,9 +102,7 @@ def test_disable_all_channels_sends_command(self): self._instr.disable_all_channels() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x10\x02\x00\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x00\x01\x50\x01")) def test_get_channel_state_for_channel_four_throws_exception(self): """Test get state of channel 4, throws exception.""" @@ -137,9 +125,7 @@ def test_get_channel_state_for_disabled_channel_1_sends_command_and_returns_disa # Assert self.assertEqual(state, AptChannelState.DISABLE) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabled_value( self, @@ -153,9 +139,7 @@ def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabl # Assert self.assertEqual(state, AptChannelState.ENABLE) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disabled_value( self, @@ -170,9 +154,7 @@ def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disa # Assert self.assertEqual(state, AptChannelState.DISABLE) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) def test_start_auto_status_update_sends_command(self): """Test start automatic status updates, sends command""" @@ -182,9 +164,7 @@ def test_start_auto_status_update_sends_command(self): self._instr.start_auto_status_update() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x00\x00\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x00\x00\x00\x50\x01")) def test_stop_auto_status_update_sends_command(self): """Test stop automatic status updates, sends command""" @@ -194,9 +174,7 @@ def test_stop_auto_status_update_sends_command(self): self._instr.stop_auto_status_update() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x12\x00\x00\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x12\x00\x00\x00\x50\x01")) def test_home_channel_3_sends_command(self): """Test home channel 3, sends command""" @@ -206,9 +184,7 @@ def test_home_channel_3_sends_command(self): self._instr.home_channel(3) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x43\x04\x04\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x43\x04\x04\x00\x50\x01")) def test_is_channel_1_homed_when_channel_homed_sends_command_returns_status(self): """Test is_channel_homed for channel 1 when channel is homed, send command and return homed value.""" @@ -235,6 +211,56 @@ def test_is_channel_1_homed_when_channel_homed_sends_command_returns_status(self ] ) + def test_move_absolute_sends_move_command(self): + """Test move channel 1, sends move command.""" + # Arrange + expected_position = 10 + + # Act + self._instr.move_absolute(1, expected_position) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x53\x04\x06\x00\xd0\x01\x01\x00\x51\x00\x00\x00") + ) + + def test_is_channel_1_move_completed_when_channel_move_completed_sends_command_returns_status(self): + """Test is_move_completed for channel 1 when channel is moved, send command and return move completed value.""" + # Arrange + # first 2 binary strings are responses from the status update command + # last string is the homed response + self._transport_mock.read.side_effect = [ + b"\x91\x04\x0e\x00\x00\x81", + b"\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + b"\x64\x04\x01\x00\x00\x81", + ] + + # Act + state = self._instr.is_move_completed(1) + + # Assert + self.assertTrue(state) + self._transport_mock.write.assert_not_called() + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=14, timeout=1.0), + unittest.mock.call(nbytes=6, timeout=1.0), + ] + ) + + # def test_save_parameter_settings_for_given_command_sends_command(self): + # """Test save_parameter_settings for channel 1 for a specific command, send command.""" + # # Arrange + + # # Act + # self._instr.save_parameter_settings(1, 0000) + + # # Assert + # self._transport_mock.write.assert_called_once_with( + # bytearray(b"\x53\x04\x06\x00\xd0\x01\x01\x00\x51\x00\x00\x00") + # ) + if __name__ == "__main__": unittest.main() From d20895af6e25023954bcaa20d9be5dbb50685bb8 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 00:14:27 +0200 Subject: [PATCH 25/37] all tests --- qmi/instruments/thorlabs/apt_packets.py | 6 +- qmi/instruments/thorlabs/mpc320.py | 3 +- tests/instruments/thorlabs/test_mpc320.py | 297 ++++++++++++++++++++-- 3 files changed, 279 insertions(+), 27 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index 6004e380..aff331db 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -9,6 +9,7 @@ apt_char, apt_word, apt_byte, + apt_short, ) @@ -137,7 +138,8 @@ class MOT_GET_USTATUSUPDATE(AptMessage): Fields: chan_ident: The channel being addressed. position: The position in encoder counts. - velocity: Velocity in controller units. + velocity: Velocity in controller units. Note that this is reported as a 16 bit + unsigned integer in the manual but it is actually signed according to the example. motor_current: Motor current in mA. status_bits: Status bits that provide various errors and indications. """ @@ -147,7 +149,7 @@ class MOT_GET_USTATUSUPDATE(AptMessage): ("chan_ident", apt_word), ("position", apt_long), ("velocity", apt_word), - ("motor_current", apt_word), + ("motor_current", apt_short), ("status_bits", apt_dword), ] diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index d7f95abe..1be14d71 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -356,6 +356,7 @@ def move_absolute(self, channel_number: int, position: float) -> None: """ _logger.info("[%s] Moving channel %d", self._name, channel_number) self._validate_channel(channel_number) + self._validate_position(position) self._check_is_open() # Convert position in degrees to encoder counts. encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) @@ -408,7 +409,7 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: Parameters: channel_number: The channel to address. - message_id: ID of message whose parameters need to be saved. + message_id: ID of message whose parameters need to be saved. Must be provided as a hex number e.g. 0x04B6 """ _logger.info("[%s] Saving parameters of message %d", self._name, message_id) self._check_is_open() diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py index ccc4818a..2b61f947 100644 --- a/tests/instruments/thorlabs/test_mpc320.py +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -5,7 +5,10 @@ from qmi.core.exceptions import QMI_InstrumentException from qmi.core.transport import QMI_SerialTransport from qmi.instruments.thorlabs import Thorlabs_Mpc320 -from qmi.instruments.thorlabs.apt_protocol import AptChannelState +from qmi.instruments.thorlabs.apt_protocol import ( + AptChannelJogDirection, + AptChannelState, +) class TestThorlabsMPC320(unittest.TestCase): @@ -18,7 +21,9 @@ def setUp(self): "qmi.instruments.thorlabs.mpc320.create_transport", return_value=self._transport_mock, ): - self._instr: Thorlabs_Mpc320 = qmi.make_instrument("test_mpc320", Thorlabs_Mpc320, "serial:transport_str") + self._instr: Thorlabs_Mpc320 = qmi.make_instrument( + "test_mpc320", Thorlabs_Mpc320, "serial:transport_str" + ) self._instr.open() def tearDown(self): @@ -47,7 +52,9 @@ def test_get_idn_sends_command_and_returns_identification_info(self): self.assertEqual(idn.serial, expected_idn[2]) self.assertEqual(idn.version, expected_idn[3]) - self._transport_mock.write.assert_called_once_with(bytearray(b"\x05\x00\x00\x00P\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x05\x00\x00\x00P\x01") + ) self._transport_mock.read.assert_has_calls( [ unittest.mock.call(nbytes=6, timeout=1.0), @@ -63,7 +70,9 @@ def test_identify_sends_command(self): self._instr.identify() # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x23\x02\x01\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x23\x02\x01\x00\x50\x01") + ) def test_enable_channels_enable_four_throws_exception(self): """Test enable channel 4, throws exception.""" @@ -82,7 +91,9 @@ def test_enable_channels_enable_one_sends_command(self): self._instr.enable_channels([1]) # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x01\x01\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x10\x02\x01\x01\x50\x01") + ) def test_enable_channels_enable_one_and_three_sends_command(self): """Test enable channels 1 and 3, sends command to enable channels 1 and 3.""" @@ -92,7 +103,9 @@ def test_enable_channels_enable_one_and_three_sends_command(self): self._instr.enable_channels([1, 3]) # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x05\x01\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x10\x02\x05\x01\x50\x01") + ) def test_disable_all_channels_sends_command(self): """Test disable all channels, sends command to disable all channels.""" @@ -102,7 +115,9 @@ def test_disable_all_channels_sends_command(self): self._instr.disable_all_channels() # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x00\x01\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x10\x02\x00\x01\x50\x01") + ) def test_get_channel_state_for_channel_four_throws_exception(self): """Test get state of channel 4, throws exception.""" @@ -125,7 +140,9 @@ def test_get_channel_state_for_disabled_channel_1_sends_command_and_returns_disa # Assert self.assertEqual(state, AptChannelState.DISABLE) - self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x02\x01\x00\x50\x01") + ) def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabled_value( self, @@ -139,7 +156,9 @@ def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabl # Assert self.assertEqual(state, AptChannelState.ENABLE) - self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x02\x01\x00\x50\x01") + ) def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disabled_value( self, @@ -154,7 +173,9 @@ def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disa # Assert self.assertEqual(state, AptChannelState.DISABLE) - self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x02\x01\x00\x50\x01") + ) def test_start_auto_status_update_sends_command(self): """Test start automatic status updates, sends command""" @@ -164,7 +185,9 @@ def test_start_auto_status_update_sends_command(self): self._instr.start_auto_status_update() # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x00\x00\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x11\x00\x00\x00\x50\x01") + ) def test_stop_auto_status_update_sends_command(self): """Test stop automatic status updates, sends command""" @@ -174,7 +197,9 @@ def test_stop_auto_status_update_sends_command(self): self._instr.stop_auto_status_update() # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x12\x00\x00\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x12\x00\x00\x00\x50\x01") + ) def test_home_channel_3_sends_command(self): """Test home channel 3, sends command""" @@ -184,7 +209,9 @@ def test_home_channel_3_sends_command(self): self._instr.home_channel(3) # Assert - self._transport_mock.write.assert_called_once_with(bytearray(b"\x43\x04\x04\x00\x50\x01")) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x43\x04\x04\x00\x50\x01") + ) def test_is_channel_1_homed_when_channel_homed_sends_command_returns_status(self): """Test is_channel_homed for channel 1 when channel is homed, send command and return homed value.""" @@ -224,14 +251,25 @@ def test_move_absolute_sends_move_command(self): bytearray(b"\x53\x04\x06\x00\xd0\x01\x01\x00\x51\x00\x00\x00") ) - def test_is_channel_1_move_completed_when_channel_move_completed_sends_command_returns_status(self): + def test_move_absolute_outside_range_thorws_error(self): + """Test move channel 1 outside valid range, throws error.""" + # Arrange + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.move_absolute(1, 20000) + + def test_is_channel_1_move_completed_when_channel_move_completed_sends_command_returns_status( + self, + ): """Test is_move_completed for channel 1 when channel is moved, send command and return move completed value.""" # Arrange # first 2 binary strings are responses from the status update command # last string is the homed response self._transport_mock.read.side_effect = [ b"\x91\x04\x0e\x00\x00\x81", - b"\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", b"\x64\x04\x01\x00\x00\x81", ] @@ -249,17 +287,228 @@ def test_is_channel_1_move_completed_when_channel_move_completed_sends_command_r ] ) - # def test_save_parameter_settings_for_given_command_sends_command(self): - # """Test save_parameter_settings for channel 1 for a specific command, send command.""" - # # Arrange + def test_save_parameter_settings_for_given_command_sends_command(self): + """Test save_parameter_settings for channel 1 for a specific command, send command.""" + # Arrange + + # Act + self._instr.save_parameter_settings(1, 0x04B6) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\xb9\x04\x04\x00\xd0\x01\x01\x00\xb6\x04") + ) + + def test_get_status_updated_of_channel_1_sends_command_returns_status( + self, + ): + """Test get_status_update for channel 1, send command and return status.""" + # Arrange + self._transport_mock.read.side_effect = [ + b"\x91\x04\x0e\x00\x00\x81", + b"\x01\x00\x51\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00", + ] + + # Act + status = self._instr.get_status_update(1) + + # Assert + self.assertEqual(status.channel, 1) + self.assertEqual(round(status.position), 10) + self.assertEqual(status.velocity, 0) + self.assertEqual(status.motor_current, -1) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x90\x04\x01\x00\x50\x01") + ) + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=14, timeout=1.0), + ] + ) + + def test_jog_forward_sends_command(self): + """Test jog forward for channel 1, sends command.""" + # Arrange + + # Act + self._instr.jog(1, AptChannelJogDirection.FORWARD) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x6a\x04\x01\x01\x50\x01") + ) + + def test_jog_backward_sends_command(self): + """Test jog backward for channel 1, sends command.""" + # Arrange + + # Act + self._instr.jog(1, AptChannelJogDirection.BACKWARD) + + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x6a\x04\x01\x02\x50\x01") + ) + + def test_set_polarisation_parameters_sends_command(self): + """Test set_polarisation_parameters, sends command.""" + # Arrange + vel = 50 + home_pos = 85 + jog_step1 = 0 + jog_step2 = 17 + jog_step3 = 34 - # # Act - # self._instr.save_parameter_settings(1, 0000) + # Act + self._instr.set_polarisation_parameters( + vel, + home_pos, + jog_step1, + jog_step2, + jog_step3, + ) - # # Assert - # self._transport_mock.write.assert_called_once_with( - # bytearray(b"\x53\x04\x06\x00\xd0\x01\x01\x00\x51\x00\x00\x00") - # ) + # Assert + self._transport_mock.write.assert_called_once_with( + bytearray( + b"\x30\x05\x0c\x00\xd0\x01\x00\x00\x32\x00\xad\x02\x00\x00\x89\x00\x12\x01" + ) + ) + + def test_set_polarisation_parameters_with_invalid_velocity_throws_error(self): + """Test set_polarisation_parameters with invalid velocity, throws error.""" + # Arrange + vel = 120 + home_pos = 85 + jog_step1 = 0 + jog_step2 = 17 + jog_step3 = 34 + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.set_polarisation_parameters( + vel, + home_pos, + jog_step1, + jog_step2, + jog_step3, + ) + + def test_set_polarisation_parameters_with_invalid_home_position_throws_error(self): + """Test set_polarisation_parameters with invalid home position, throws error.""" + # Arrange + vel = 50 + home_pos = 200 + jog_step1 = 0 + jog_step2 = 17 + jog_step3 = 34 + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.set_polarisation_parameters( + vel, + home_pos, + jog_step1, + jog_step2, + jog_step3, + ) + + def test_set_polarisation_parameters_with_invalid_jog_step_1_throws_error(self): + """Test set_polarisation_parameters with invalid jog step 1, throws error.""" + # Arrange + vel = 50 + home_pos = 85 + jog_step1 = 300 + jog_step2 = 17 + jog_step3 = 34 + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.set_polarisation_parameters( + vel, + home_pos, + jog_step1, + jog_step2, + jog_step3, + ) + + def test_set_polarisation_parameters_with_invalid_jog_step_2_throws_error(self): + """Test set_polarisation_parameters with invalid jog step 2, throws error.""" + # Arrange + vel = 50 + home_pos = 85 + jog_step1 = 0 + jog_step2 = 240 + jog_step3 = 34 + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.set_polarisation_parameters( + vel, + home_pos, + jog_step1, + jog_step2, + jog_step3, + ) + + def test_set_polarisation_parameters_with_invalid_jog_step_3_throws_error(self): + """Test set_polarisation_parameters with invalid jog step 3, throws error.""" + # Arrange + vel = 50 + home_pos = 85 + jog_step1 = 0 + jog_step2 = 17 + jog_step3 = 310 + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + self._instr.set_polarisation_parameters( + vel, + home_pos, + jog_step1, + jog_step2, + jog_step3, + ) + + def test_get_polarisation_parameters_sends_command_returns_parameters( + self, + ): + """Test get_polarisation_parameters, sends command and returns parameters.""" + # Arrange + expected_velocity = 50 + expected_home_position = 85 + expected_jog_step_1 = 0 + expected_jog_step_2 = 17 + expected_jog_step_3 = 34 + self._transport_mock.read.side_effect = [ + b"\x32\x05\x0c\x00\x00\x81", + b"\x00\x00\x32\x00\xad\x02\x00\x00\x89\x00\x12\x01", + ] + + # Act + params = self._instr.get_polarisation_parameters() + + # Assert + self.assertEqual(params.velocity, expected_velocity) + self.assertEqual(params.home_position, expected_home_position) + self.assertEqual(params.jog_step1, expected_jog_step_1) + self.assertEqual(params.jog_step2, expected_jog_step_2) + self.assertEqual(params.jog_step3, expected_jog_step_3) + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x31\x05\x00\x00\x50\x01") + ) + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=12, timeout=1.0), + ] + ) if __name__ == "__main__": From 92c301559b8c863c403d50e4dc8e650655a891cd Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 00:19:09 +0200 Subject: [PATCH 26/37] test to check wrong msg id branch --- tests/instruments/thorlabs/test_mpc320.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py index 2b61f947..4e0c2b93 100644 --- a/tests/instruments/thorlabs/test_mpc320.py +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -62,6 +62,28 @@ def test_get_idn_sends_command_and_returns_identification_info(self): ] ) + def test_get_idn_with_wrong_returned_msg_id_sends_command_and_throws_error(self): + """Test get_idn method and returns identification info.""" + # Arrange + self._transport_mock.read.side_effect = [ + b"\x11\x00\x54\x00\x00\x81", + b"\x89\x53\x9a\x05\x4d\x50\x43\x33\x32\x30\x20\x00\x2c\x00\x02\x01\x39\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ] + + # Act + # Assert + with self.assertRaises(QMI_InstrumentException): + _ = self._instr.get_idn() + self._transport_mock.write.assert_called_once_with( + bytearray(b"\x05\x00\x00\x00P\x01") + ) + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=84, timeout=1.0), + ] + ) + def test_identify_sends_command(self): """Test identify method and send relevant command.""" # Arrange From d55e5b7a4aaf3a17ccf757f8e5d070b924f7e415 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:28:40 +0200 Subject: [PATCH 27/37] try catch for move and homing checks --- CHANGELOG.md | 1 + qmi/instruments/thorlabs/mpc320.py | 73 +++++------- tests/instruments/thorlabs/test_mpc320.py | 130 ++++++++++++---------- 3 files changed, 100 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68aedc65..5939daa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - QMI driver for TeraXion TFN in `qmi.instruments.teraxion` with CLI client. +- QMI driver for Thorlabs MPC320 in `qmi.instruments.thorlabs`. ### Changed - In `setup.py` limited NumPy and SciPy versions to be <2. Also added missing line for Tenma 72 PSU CLI. diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 1be14d71..481b8ae0 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -4,7 +4,7 @@ import logging from typing import Dict, List from qmi.core.context import QMI_Context -from qmi.core.exceptions import QMI_InstrumentException +from qmi.core.exceptions import QMI_InstrumentException, QMI_TimeoutException from qmi.core.instrument import QMI_Instrument, QMI_InstrumentIdentification from qmi.core.rpc import rpc_method from qmi.core.transport import create_transport @@ -98,12 +98,8 @@ def __init__(self, context: QMI_Context, name: str, transport: str) -> None: transport: QMI transport descriptor to connect to the instrument. """ super().__init__(context, name) - self._transport = create_transport( - transport, default_attributes={"baudrate": 115200, "rtscts": True} - ) - self._apt_protocol = AptProtocol( - self._transport, default_timeout=self.DEFAULT_RESPONSE_TIMEOUT - ) + self._transport = create_transport(transport, default_attributes={"baudrate": 115200, "rtscts": True}) + self._apt_protocol = AptProtocol(self._transport, default_timeout=self.DEFAULT_RESPONSE_TIMEOUT) def _validate_position(self, pos: float) -> None: """ @@ -183,9 +179,7 @@ def get_idn(self) -> QMI_InstrumentIdentification: self._apt_protocol.write_param_command(AptMessageId.HW_REQ_INFO.value) # Get response resp = self._apt_protocol.ask(HW_GET_INFO) - return QMI_InstrumentIdentification( - "Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version - ) + return QMI_InstrumentIdentification("Thorlabs", resp.model_number, resp.serial_number, resp.firmware_version) @rpc_method def identify(self) -> None: @@ -274,9 +268,7 @@ def start_auto_status_update(self) -> None: """ Start automatic status updates from device. """ - _logger.info( - "[%s] Starting automatic status updates from instrument", self._name - ) + _logger.info("[%s] Starting automatic status updates from instrument", self._name) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.HW_START_UPDATEMSGS.value) @@ -286,9 +278,7 @@ def stop_auto_status_update(self) -> None: """ Stop automatic status updates from device. """ - _logger.info( - "[%s] Stopping automatic status updates from instrument", self._name - ) + _logger.info("[%s] Stopping automatic status updates from instrument", self._name) self._check_is_open() # Send message. self._apt_protocol.write_param_command(AptMessageId.HW_STOP_UPDATEMSGS.value) @@ -312,9 +302,7 @@ def home_channel(self, channel_number: int) -> None: ) @rpc_method - def is_channel_homed( - self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT - ) -> bool: + def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: """ Check if a given channel is homed. This command should only be run after the method `home_channel`. Otherwise you will read bytes from other commands using this method. @@ -334,12 +322,13 @@ def is_channel_homed( # its current state first, so read that to discard the buffer _ = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE, timeout) # then read the actual response we need - # TODO: check what happens when the channel is not homed - # is no response received? - resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) + try: + resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) + return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] + except QMI_TimeoutException: + _logger.debug("[%s] Channel %d not homed yet", self._name, channel_number) - # Check if the channel number in the response is equal to the one that was asked for. - return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] + return False @rpc_method def move_absolute(self, channel_number: int, position: float) -> None: @@ -366,14 +355,10 @@ def move_absolute(self, channel_number: int, position: float) -> None: absolute_distance=encoder_position, ) # Send message. - self._apt_protocol.write_data_command( - AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet - ) + self._apt_protocol.write_data_command(AptMessageId.MOT_MOVE_ABSOLUTE.value, data_packet) @rpc_method - def is_move_completed( - self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT - ) -> bool: + def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> bool: """ Check if a given channel has completed its move. This command should only be run after a relative or absolute move command. Otherwise you will read bytes from other commands. @@ -397,7 +382,13 @@ def is_move_completed( # its current state first, so read that to discard the buffer _ = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE, timeout) # then read the actual response we need - resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) + # then read the actual response we need. If the call times out then the channel has not finished its move. + try: + resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) + return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] + except QMI_TimeoutException: + _logger.debug("[%s] Channel %d move not completed yet", self._name, channel_number) + return False return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] @@ -415,13 +406,9 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: self._check_is_open() self._validate_channel(channel_number) # Make data packet. - data_packet = MOT_SET_EEPROMPARAMS( - chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], msg_id=message_id - ) + data_packet = MOT_SET_EEPROMPARAMS(chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], msg_id=message_id) # Send message. - self._apt_protocol.write_data_command( - AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet - ) + self._apt_protocol.write_data_command(AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet) @rpc_method def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: @@ -435,9 +422,7 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: Returns: An instance of Thorlabs_MPC320_Status. """ - _logger.info( - "[%s] Getting position counter of channel %d", self._name, channel_number - ) + _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) self._check_is_open() self._validate_channel(channel_number) # Send request message. @@ -467,9 +452,7 @@ def jog( channel_number: The channel to job. direction: The direction to job. This can either be forward or backward. Default is forward. """ - _logger.info( - "[%s] Getting position counter of channel %d", self._name, channel_number - ) + _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) self._check_is_open() self._validate_channel(channel_number) # Send request message. @@ -515,9 +498,7 @@ def set_polarisation_parameters( jog_step3=round(jog_step3 / self.ENCODER_CONVERSION_UNIT), ) # Send message. - self._apt_protocol.write_data_command( - AptMessageId.POL_SET_PARAMS.value, data_packet - ) + self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) @rpc_method def get_polarisation_parameters(self) -> Thorlabs_MPC320_PolarisationParameters: diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py index 4e0c2b93..8644bf92 100644 --- a/tests/instruments/thorlabs/test_mpc320.py +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -2,7 +2,7 @@ import unittest import unittest.mock import qmi -from qmi.core.exceptions import QMI_InstrumentException +from qmi.core.exceptions import QMI_InstrumentException, QMI_TimeoutException from qmi.core.transport import QMI_SerialTransport from qmi.instruments.thorlabs import Thorlabs_Mpc320 from qmi.instruments.thorlabs.apt_protocol import ( @@ -21,9 +21,7 @@ def setUp(self): "qmi.instruments.thorlabs.mpc320.create_transport", return_value=self._transport_mock, ): - self._instr: Thorlabs_Mpc320 = qmi.make_instrument( - "test_mpc320", Thorlabs_Mpc320, "serial:transport_str" - ) + self._instr: Thorlabs_Mpc320 = qmi.make_instrument("test_mpc320", Thorlabs_Mpc320, "serial:transport_str") self._instr.open() def tearDown(self): @@ -52,9 +50,7 @@ def test_get_idn_sends_command_and_returns_identification_info(self): self.assertEqual(idn.serial, expected_idn[2]) self.assertEqual(idn.version, expected_idn[3]) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x05\x00\x00\x00P\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x05\x00\x00\x00P\x01")) self._transport_mock.read.assert_has_calls( [ unittest.mock.call(nbytes=6, timeout=1.0), @@ -74,9 +70,7 @@ def test_get_idn_with_wrong_returned_msg_id_sends_command_and_throws_error(self) # Assert with self.assertRaises(QMI_InstrumentException): _ = self._instr.get_idn() - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x05\x00\x00\x00P\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x05\x00\x00\x00P\x01")) self._transport_mock.read.assert_has_calls( [ unittest.mock.call(nbytes=6, timeout=1.0), @@ -92,9 +86,7 @@ def test_identify_sends_command(self): self._instr.identify() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x23\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x23\x02\x01\x00\x50\x01")) def test_enable_channels_enable_four_throws_exception(self): """Test enable channel 4, throws exception.""" @@ -113,9 +105,7 @@ def test_enable_channels_enable_one_sends_command(self): self._instr.enable_channels([1]) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x10\x02\x01\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x01\x01\x50\x01")) def test_enable_channels_enable_one_and_three_sends_command(self): """Test enable channels 1 and 3, sends command to enable channels 1 and 3.""" @@ -125,9 +115,7 @@ def test_enable_channels_enable_one_and_three_sends_command(self): self._instr.enable_channels([1, 3]) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x10\x02\x05\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x05\x01\x50\x01")) def test_disable_all_channels_sends_command(self): """Test disable all channels, sends command to disable all channels.""" @@ -137,9 +125,7 @@ def test_disable_all_channels_sends_command(self): self._instr.disable_all_channels() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x10\x02\x00\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x10\x02\x00\x01\x50\x01")) def test_get_channel_state_for_channel_four_throws_exception(self): """Test get state of channel 4, throws exception.""" @@ -162,9 +148,7 @@ def test_get_channel_state_for_disabled_channel_1_sends_command_and_returns_disa # Assert self.assertEqual(state, AptChannelState.DISABLE) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabled_value( self, @@ -178,9 +162,7 @@ def test_get_channel_state_for_enabled_channel_1_sends_command_and_returns_enabl # Assert self.assertEqual(state, AptChannelState.ENABLE) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disabled_value( self, @@ -195,9 +177,7 @@ def test_get_channel_state_for_disabled_channel_2_sends_command_and_returns_disa # Assert self.assertEqual(state, AptChannelState.DISABLE) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x02\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x02\x01\x00\x50\x01")) def test_start_auto_status_update_sends_command(self): """Test start automatic status updates, sends command""" @@ -207,9 +187,7 @@ def test_start_auto_status_update_sends_command(self): self._instr.start_auto_status_update() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x11\x00\x00\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x11\x00\x00\x00\x50\x01")) def test_stop_auto_status_update_sends_command(self): """Test stop automatic status updates, sends command""" @@ -219,9 +197,7 @@ def test_stop_auto_status_update_sends_command(self): self._instr.stop_auto_status_update() # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x12\x00\x00\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x12\x00\x00\x00\x50\x01")) def test_home_channel_3_sends_command(self): """Test home channel 3, sends command""" @@ -231,9 +207,7 @@ def test_home_channel_3_sends_command(self): self._instr.home_channel(3) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x43\x04\x04\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x43\x04\x04\x00\x50\x01")) def test_is_channel_1_homed_when_channel_homed_sends_command_returns_status(self): """Test is_channel_homed for channel 1 when channel is homed, send command and return homed value.""" @@ -260,6 +234,31 @@ def test_is_channel_1_homed_when_channel_homed_sends_command_returns_status(self ] ) + def test_is_channel_1_homed_with_read_timing_out_sends_command_returns_status(self): + """Test is_channel_homed for channel 1 when read times out, send command and returns not homed.""" + # Arrange + # first 2 binary strings are responses from the status update command + # last string is the homed response + self._transport_mock.read.side_effect = [ + b"\x91\x04\x0e\x00\x00\x81", + b"\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + QMI_TimeoutException(), + ] + + # Act + state = self._instr.is_channel_homed(1) + + # Assert + self.assertFalse(state) + self._transport_mock.write.assert_not_called() + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=14, timeout=1.0), + unittest.mock.call(nbytes=6, timeout=1.0), + ] + ) + def test_move_absolute_sends_move_command(self): """Test move channel 1, sends move command.""" # Arrange @@ -309,6 +308,33 @@ def test_is_channel_1_move_completed_when_channel_move_completed_sends_command_r ] ) + def test_is_channel_1_move_completed_with_read_timing_out_sends_command_returns_status( + self, + ): + """Test is_move_completed for channel 1 when read times out, sends command and returns move not completed.""" + # Arrange + # first 2 binary strings are responses from the status update command + # last string is the homed response + self._transport_mock.read.side_effect = [ + b"\x91\x04\x0e\x00\x00\x81", + b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + QMI_TimeoutException, + ] + + # Act + state = self._instr.is_move_completed(1) + + # Assert + self.assertFalse(state) + self._transport_mock.write.assert_not_called() + self._transport_mock.read.assert_has_calls( + [ + unittest.mock.call(nbytes=6, timeout=1.0), + unittest.mock.call(nbytes=14, timeout=1.0), + unittest.mock.call(nbytes=6, timeout=1.0), + ] + ) + def test_save_parameter_settings_for_given_command_sends_command(self): """Test save_parameter_settings for channel 1 for a specific command, send command.""" # Arrange @@ -317,9 +343,7 @@ def test_save_parameter_settings_for_given_command_sends_command(self): self._instr.save_parameter_settings(1, 0x04B6) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\xb9\x04\x04\x00\xd0\x01\x01\x00\xb6\x04") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\xb9\x04\x04\x00\xd0\x01\x01\x00\xb6\x04")) def test_get_status_updated_of_channel_1_sends_command_returns_status( self, @@ -339,9 +363,7 @@ def test_get_status_updated_of_channel_1_sends_command_returns_status( self.assertEqual(round(status.position), 10) self.assertEqual(status.velocity, 0) self.assertEqual(status.motor_current, -1) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x90\x04\x01\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x90\x04\x01\x00\x50\x01")) self._transport_mock.read.assert_has_calls( [ unittest.mock.call(nbytes=6, timeout=1.0), @@ -357,9 +379,7 @@ def test_jog_forward_sends_command(self): self._instr.jog(1, AptChannelJogDirection.FORWARD) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x6a\x04\x01\x01\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x6a\x04\x01\x01\x50\x01")) def test_jog_backward_sends_command(self): """Test jog backward for channel 1, sends command.""" @@ -369,9 +389,7 @@ def test_jog_backward_sends_command(self): self._instr.jog(1, AptChannelJogDirection.BACKWARD) # Assert - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x6a\x04\x01\x02\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x6a\x04\x01\x02\x50\x01")) def test_set_polarisation_parameters_sends_command(self): """Test set_polarisation_parameters, sends command.""" @@ -393,9 +411,7 @@ def test_set_polarisation_parameters_sends_command(self): # Assert self._transport_mock.write.assert_called_once_with( - bytearray( - b"\x30\x05\x0c\x00\xd0\x01\x00\x00\x32\x00\xad\x02\x00\x00\x89\x00\x12\x01" - ) + bytearray(b"\x30\x05\x0c\x00\xd0\x01\x00\x00\x32\x00\xad\x02\x00\x00\x89\x00\x12\x01") ) def test_set_polarisation_parameters_with_invalid_velocity_throws_error(self): @@ -522,9 +538,7 @@ def test_get_polarisation_parameters_sends_command_returns_parameters( self.assertEqual(params.jog_step1, expected_jog_step_1) self.assertEqual(params.jog_step2, expected_jog_step_2) self.assertEqual(params.jog_step3, expected_jog_step_3) - self._transport_mock.write.assert_called_once_with( - bytearray(b"\x31\x05\x00\x00\x50\x01") - ) + self._transport_mock.write.assert_called_once_with(bytearray(b"\x31\x05\x00\x00\x50\x01")) self._transport_mock.read.assert_has_calls( [ unittest.mock.call(nbytes=6, timeout=1.0), From b0e1bfb43abb2d1b4bbe9b81125bdaa071228575 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:31:34 +0200 Subject: [PATCH 28/37] added missing docstring --- qmi/instruments/thorlabs/mpc320.py | 37 ++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 481b8ae0..977ff646 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -30,7 +30,7 @@ @dataclass -class Thorlabs_MPC320_Status: +class Thorlabs_Mpc320_Status: """ Data class for the status of the MPC320 @@ -48,7 +48,7 @@ class Thorlabs_MPC320_Status: @dataclass -class Thorlabs_MPC320_PolarisationParameters: +class Thorlabs_Mpc320_PolarisationParameters: """ Data class for the polarisation parameters of the MPC320 @@ -67,7 +67,7 @@ class Thorlabs_MPC320_PolarisationParameters: jog_step3: float -Thorlabs_MPC320_ChannelMap: Dict[int, int] = {1: 0x01, 2: 0x02, 3: 0x04} +Thorlabs_Mpc320_ChannelMap: Dict[int, int] = {1: 0x01, 2: 0x02, 3: 0x04} class Thorlabs_Mpc320(QMI_Instrument): @@ -216,7 +216,7 @@ def enable_channels(self, channel_numbers: List[int]) -> None: # Make hexadecimal value for channels channels_to_enable = 0x00 for channel_number in channel_numbers: - channels_to_enable ^= Thorlabs_MPC320_ChannelMap[channel_number] + channels_to_enable ^= Thorlabs_Mpc320_ChannelMap[channel_number] # Send message. self._apt_protocol.write_param_command( AptMessageId.MOD_SET_CHANENABLESTATE.value, @@ -254,7 +254,7 @@ def get_channel_state(self, channel_number: int) -> AptChannelState: # Send request message. self._apt_protocol.write_param_command( AptMessageId.MOD_REQ_CHANENABLESTATE.value, - Thorlabs_MPC320_ChannelMap[channel_number], + Thorlabs_Mpc320_ChannelMap[channel_number], ) # Get response resp = self._apt_protocol.ask(MOD_GET_CHANENABLESTATE) @@ -298,7 +298,7 @@ def home_channel(self, channel_number: int) -> None: self._check_is_open() # Send message. self._apt_protocol.write_param_command( - AptMessageId.MOT_MOVE_HOME.value, Thorlabs_MPC320_ChannelMap[channel_number] + AptMessageId.MOT_MOVE_HOME.value, Thorlabs_Mpc320_ChannelMap[channel_number] ) @rpc_method @@ -324,7 +324,7 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS # then read the actual response we need try: resp = self._apt_protocol.ask(MOT_MOVE_HOMED, timeout) - return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] + return resp.chan_ident == Thorlabs_Mpc320_ChannelMap[channel_number] except QMI_TimeoutException: _logger.debug("[%s] Channel %d not homed yet", self._name, channel_number) @@ -351,7 +351,7 @@ def move_absolute(self, channel_number: int, position: float) -> None: encoder_position = round(position / self.ENCODER_CONVERSION_UNIT) # Make data packet. data_packet = MOT_MOVE_ABSOLUTE( - chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], + chan_ident=Thorlabs_Mpc320_ChannelMap[channel_number], absolute_distance=encoder_position, ) # Send message. @@ -385,12 +385,12 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON # then read the actual response we need. If the call times out then the channel has not finished its move. try: resp = self._apt_protocol.ask(MOT_MOVE_COMPLETED, timeout) - return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] + return resp.chan_ident == Thorlabs_Mpc320_ChannelMap[channel_number] except QMI_TimeoutException: _logger.debug("[%s] Channel %d move not completed yet", self._name, channel_number) return False - return resp.chan_ident == Thorlabs_MPC320_ChannelMap[channel_number] + return resp.chan_ident == Thorlabs_Mpc320_ChannelMap[channel_number] @rpc_method def save_parameter_settings(self, channel_number: int, message_id: int) -> None: @@ -406,12 +406,12 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: self._check_is_open() self._validate_channel(channel_number) # Make data packet. - data_packet = MOT_SET_EEPROMPARAMS(chan_ident=Thorlabs_MPC320_ChannelMap[channel_number], msg_id=message_id) + data_packet = MOT_SET_EEPROMPARAMS(chan_ident=Thorlabs_Mpc320_ChannelMap[channel_number], msg_id=message_id) # Send message. self._apt_protocol.write_data_command(AptMessageId.MOT_SET_EEPROMPARAMS.value, data_packet) @rpc_method - def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: + def get_status_update(self, channel_number: int) -> Thorlabs_Mpc320_Status: """ Get the status update for a given channel. This call will return the position, velocity, motor current and status of the channel. @@ -428,11 +428,11 @@ def get_status_update(self, channel_number: int) -> Thorlabs_MPC320_Status: # Send request message. self._apt_protocol.write_param_command( AptMessageId.MOT_REQ_USTATUSUPDATE.value, - Thorlabs_MPC320_ChannelMap[channel_number], + Thorlabs_Mpc320_ChannelMap[channel_number], ) # Get response resp = self._apt_protocol.ask(MOT_GET_USTATUSUPDATE) - return Thorlabs_MPC320_Status( + return Thorlabs_Mpc320_Status( channel=channel_number, position=resp.position * self.ENCODER_CONVERSION_UNIT, velocity=resp.velocity, @@ -458,7 +458,7 @@ def jog( # Send request message. self._apt_protocol.write_param_command( AptMessageId.MOT_MOVE_JOG.value, - Thorlabs_MPC320_ChannelMap[channel_number], + Thorlabs_Mpc320_ChannelMap[channel_number], direction.value, ) @@ -501,9 +501,12 @@ def set_polarisation_parameters( self._apt_protocol.write_data_command(AptMessageId.POL_SET_PARAMS.value, data_packet) @rpc_method - def get_polarisation_parameters(self) -> Thorlabs_MPC320_PolarisationParameters: + def get_polarisation_parameters(self) -> Thorlabs_Mpc320_PolarisationParameters: """ Get the polarisation parameters. + + Returns: + An instance of Thorlabs_Mpc320_PolarisationParameters """ _logger.info("[%s] Getting polarisation parameters", self._name) self._check_is_open() @@ -511,7 +514,7 @@ def get_polarisation_parameters(self) -> Thorlabs_MPC320_PolarisationParameters: self._apt_protocol.write_param_command(AptMessageId.POL_REQ_PARAMS.value) # Get response. params = self._apt_protocol.ask(POL_GET_SET_PARAMS) - return Thorlabs_MPC320_PolarisationParameters( + return Thorlabs_Mpc320_PolarisationParameters( velocity=params.velocity, home_position=params.home_position * self.ENCODER_CONVERSION_UNIT, jog_step1=params.jog_step1 * self.ENCODER_CONVERSION_UNIT, From 2545c1622669cd4ec7ac0461cb09bbd340eb90e8 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:31:57 +0200 Subject: [PATCH 29/37] typo --- qmi/instruments/thorlabs/mpc320.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 977ff646..5c82b3a9 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -449,8 +449,8 @@ def jog( Move a channel specified by its jog step. Parameters: - channel_number: The channel to job. - direction: The direction to job. This can either be forward or backward. Default is forward. + channel_number: The channel to jog. + direction: The direction to jog. This can either be forward or backward. Default is forward. """ _logger.info("[%s] Getting position counter of channel %d", self._name, channel_number) self._check_is_open() From 2d2f806ccec1db09c6b5a35d537babaed4d1f07e Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:33:08 +0200 Subject: [PATCH 30/37] typo --- qmi/instruments/thorlabs/mpc320.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 5c82b3a9..bee2e1b7 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -333,7 +333,7 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS @rpc_method def move_absolute(self, channel_number: int, position: float) -> None: """ - Move a channel to the specified position. The specified position is in degeres. A conversion is done to convert + Move a channel to the specified position. The specified position is in degrees. A conversion is done to convert this into encoder counts. This means that there may be a slight mismatch in the specified position and the actual position. You may use the get_status_update method to get the actual position. After running this command, you must clear the buffer by checking if the channel From 724aa962b54a7aae8bc1989679c1a0cb6c71b8f0 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:33:49 +0200 Subject: [PATCH 31/37] reformat docstring --- qmi/instruments/thorlabs/mpc320.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index bee2e1b7..73cc4477 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -309,8 +309,8 @@ def is_channel_homed(self, channel_number: int, timeout: float = DEFAULT_RESPONS Paramters: channel_number: The channel to check. - timeout: The time to wait for a response to the homing command. This is optional - and is set to a default value of DEFAULT_RESPONSE_TIMEOUT. + timeout: The time to wait for a response to the homing command + with default value DEFAULT_RESPONSE_TIMEOUT. Returns: True if the channel was homed. From 7ee1b1fc74dc00c9e07f6455378622651aa98ec3 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:34:38 +0200 Subject: [PATCH 32/37] typo --- qmi/instruments/thorlabs/apt_protocol.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index e54a28bf..4b0fe6ed 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -60,15 +60,11 @@ class AptStatusBits(Enum): P_MOT_SB_OVERLOAD = 0x01000000 # some form of motor overload P_MOT_SB_ENCODERFAULT = 0x02000000 # encoder fault P_MOT_SB_OVERCURRENT = 0x04000000 # motor exceeded continuous current limit - P_MOT_SB_BUSCURRENTFAULT = ( - 0x08000000 # excessive current being drawn from motor power supply - ) + P_MOT_SB_BUSCURRENTFAULT = 0x08000000 # excessive current being drawn from motor power supply P_MOT_SB_POWEROK = 0x10000000 # controller power supplies operating normally P_MOT_SB_ACTIVE = 0x20000000 # controller executing motion commend P_MOT_SB_ERROR = 0x40000000 # indicates an error condition - P_MOT_SB_ENABLED = ( - 0x80000000 # motor output enabled, with controller maintaining position - ) + P_MOT_SB_ENABLED = 0x80000000 # motor output enabled, with controller maintaining position class AptMessageId(Enum): @@ -132,7 +128,7 @@ class AptMessageHeaderForData(LittleEndianStructure): _pack_ = True _fields_: List[Tuple[str, type]] = [ ("message_id", c_uint16), - ("date_length", c_uint16), + ("data_length", c_uint16), ("dest", c_uint8), ("source", c_uint8), ] @@ -214,9 +210,7 @@ def write_data_command(self, message_id: int, data: T) -> None: data_length = sizeof(data) # Make the header. - msg = AptMessageHeaderForData( - message_id, data_length, self._apt_device_address | 0x80, self._host_address - ) + msg = AptMessageHeaderForData(message_id, data_length, self._apt_device_address | 0x80, self._host_address) # Send the header and data packet. self._transport.write(bytearray(msg) + bytearray(data)) @@ -237,13 +231,11 @@ def ask(self, data_type: Type[T], timeout: Optional[float] = None) -> T: timeout = self._timeout # Read the header first. - header_bytes = self._transport.read( - nbytes=self.HEADER_SIZE_BYTES, timeout=timeout - ) + header_bytes = self._transport.read(nbytes=self.HEADER_SIZE_BYTES, timeout=timeout) if data_type.HEADER_ONLY: return data_type.from_buffer_copy(header_bytes) header = AptMessageHeaderForData.from_buffer_copy(header_bytes) - data_length = header.date_length + data_length = header.data_length # Read the data packet that follows the header. data_bytes = self._transport.read(nbytes=data_length, timeout=timeout) From bed4f35614cdaa3e1423aacf90670847ba294ed8 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:37:20 +0200 Subject: [PATCH 33/37] changed default params to 0x00 --- qmi/instruments/thorlabs/apt_protocol.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qmi/instruments/thorlabs/apt_protocol.py b/qmi/instruments/thorlabs/apt_protocol.py index 4b0fe6ed..d3c01653 100644 --- a/qmi/instruments/thorlabs/apt_protocol.py +++ b/qmi/instruments/thorlabs/apt_protocol.py @@ -180,22 +180,22 @@ def __init__( def write_param_command( self, message_id: int, - param1: Optional[int] = None, - param2: Optional[int] = None, + param1: int = 0x00, + param2: int = 0x00, ) -> None: """ Send an APT protocol command that is a header (i.e. 6 bytes) with params. Parameters: message_id: ID of message to send. - param1: Optional parameter 1 to be sent. - param2: Optional parameter 2 to be sent. + param1: Parameter 1 to be sent with default value of 0x00. + param2: Parameter 2 to be sent with default value of 0x00. """ # Make the command. msg = AptMessageHeaderWithParams( message_id, - param1 or 0x00, - param2 or 0x00, + param1, + param2, self._apt_device_address, self._host_address, ) From 8621a73389b10a9d47caada6c5eecbc1aa4ef5a5 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:38:53 +0200 Subject: [PATCH 34/37] fixed docstring typo --- qmi/instruments/thorlabs/apt_packets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/instruments/thorlabs/apt_packets.py b/qmi/instruments/thorlabs/apt_packets.py index aff331db..29cfdf36 100644 --- a/qmi/instruments/thorlabs/apt_packets.py +++ b/qmi/instruments/thorlabs/apt_packets.py @@ -169,7 +169,7 @@ class MOT_SET_EEPROMPARAMS(AptMessage): class POL_GET_SET_PARAMS(AptMessage): """ " - Data packet structure for POL_SET_PARAMS command. It is also the data packet structure for the POL_SET_PARAMS. + Data packet structure for POL_SET_PARAMS command. It is also the data packet structure for the POL_GET_PARAMS. Fields: not_used: This field is not used, but needs to be in the field structure to not break it. From b2867ffc9b563ed9df4492814bc6256a61cb91de Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:41:28 +0200 Subject: [PATCH 35/37] added mpc320 to init --- qmi/instruments/thorlabs/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qmi/instruments/thorlabs/__init__.py b/qmi/instruments/thorlabs/__init__.py index b25f8470..a540b0ff 100644 --- a/qmi/instruments/thorlabs/__init__.py +++ b/qmi/instruments/thorlabs/__init__.py @@ -6,6 +6,7 @@ - MFF10X filter flip mounts - PM100D power meter - TSP01, TSP01B environmental sensors. +- MPC320 Polarisation Controller. """ from qmi.instruments.thorlabs.pm100d import SensorInfo From 18a3deef7789a8986ce10b024d8cce0c8bf97131 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 10:56:17 +0200 Subject: [PATCH 36/37] review comments --- qmi/instruments/thorlabs/mpc320.py | 10 +++++----- tests/instruments/thorlabs/test_mpc320.py | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 73cc4477..d359a901 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -110,7 +110,7 @@ def _validate_position(self, pos: float) -> None: pos: Position to validate in degrees. Raises: - an instance of QMI_InstrumentException if the position is invalid. + QMI_InstrumentException: If the position is invalid. """ if not self.MIN_POSITION_DEGREES <= pos <= self.MAX_POSITION_DEGREES: raise QMI_InstrumentException( @@ -127,7 +127,7 @@ def _validate_velocity(self, vel: float) -> None: vel: Velocity to validate in percentage. Raises: - an instance of QMI_InstrumentException if the velocity is invalid. + QMI_InstrumentException: if the velocity is invalid. """ if not self.MIN_VELOCITY_PERC <= vel <= self.MAX_VELOCITY_PERC: raise QMI_InstrumentException( @@ -143,10 +143,10 @@ def _validate_channel(self, channel_number: int) -> None: channel_number: Channel number to validate. Raises: - an instance of QMI_InstrumentException if the channel is not 1, 2 or 3 + QMI_InstrumentException: if the channel is not 1, 2 or 3 """ - if channel_number not in [1, 2, 3]: + if channel_number not in range(self.MIN_CHANNEL_NUMBER, self.MAX_CHANNEL_NUMBER + 1): raise QMI_InstrumentException( f"Given channel {channel_number} is not in the valid range \ [{self.MIN_CHANNEL_NUMBER}, {self.MAX_CHANNEL_NUMBER}]" @@ -207,7 +207,7 @@ def enable_channels(self, channel_numbers: List[int]) -> None: self.enable_channel([1,2]) Parameters: - channel_number: The channnels(s) to enable. + channel_number: The channel(s) to enable. """ _logger.info("[%s] Enabling channel(s) %s", self._name, str(channel_numbers)) self._check_is_open() diff --git a/tests/instruments/thorlabs/test_mpc320.py b/tests/instruments/thorlabs/test_mpc320.py index 8644bf92..f3141239 100644 --- a/tests/instruments/thorlabs/test_mpc320.py +++ b/tests/instruments/thorlabs/test_mpc320.py @@ -9,19 +9,23 @@ AptChannelJogDirection, AptChannelState, ) +from tests.patcher import PatcherQmiContext class TestThorlabsMPC320(unittest.TestCase): def setUp(self): # Patch QMI context and make instrument self._ctx_qmi_id = f"test-tasks-{random.randint(0, 100)}" - qmi.start(self._ctx_qmi_id) + self.qmi_patcher = PatcherQmiContext() + self.qmi_patcher.start(self._ctx_qmi_id) self._transport_mock = unittest.mock.MagicMock(spec=QMI_SerialTransport) with unittest.mock.patch( "qmi.instruments.thorlabs.mpc320.create_transport", return_value=self._transport_mock, ): - self._instr: Thorlabs_Mpc320 = qmi.make_instrument("test_mpc320", Thorlabs_Mpc320, "serial:transport_str") + self._instr: Thorlabs_Mpc320 = self.qmi_patcher.make_instrument( + "test_mpc320", Thorlabs_Mpc320, "serial:transport_str" + ) self._instr.open() def tearDown(self): From 55de244189943cb4d33ea319128e5a13f014d676 Mon Sep 17 00:00:00 2001 From: Ravi Budhrani Date: Tue, 16 Jul 2024 11:03:39 +0200 Subject: [PATCH 37/37] refactoring --- qmi/instruments/thorlabs/mpc320.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index d359a901..a1876238 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -390,8 +390,6 @@ def is_move_completed(self, channel_number: int, timeout: float = DEFAULT_RESPON _logger.debug("[%s] Channel %d move not completed yet", self._name, channel_number) return False - return resp.chan_ident == Thorlabs_Mpc320_ChannelMap[channel_number] - @rpc_method def save_parameter_settings(self, channel_number: int, message_id: int) -> None: """ @@ -400,7 +398,8 @@ def save_parameter_settings(self, channel_number: int, message_id: int) -> None: Parameters: channel_number: The channel to address. - message_id: ID of message whose parameters need to be saved. Must be provided as a hex number e.g. 0x04B6 + message_id: ID of message whose parameters need to be saved. + Must be provided as a hex number e.g. 0x04B6 """ _logger.info("[%s] Saving parameters of message %d", self._name, message_id) self._check_is_open()