├── .gitattributes ├── .gitignore ├── LICENSE ├── PyLabware ├── __init__.py ├── connections.py ├── controllers.py ├── devices │ ├── __init__.py │ ├── buchi_c815.py │ ├── buchi_r300.py │ ├── heidolph_hei_torque_100_precision.py │ ├── heidolph_rzr_2052_control.py │ ├── huber_petite_fleur.py │ ├── idex_mxii.py │ ├── ika_microstar_75.py │ ├── ika_rct_digital.py │ ├── ika_ret_control_visc.py │ ├── ika_rv10.py │ ├── julabo_cf41.py │ ├── tricontinent_c3000.py │ └── vacuubrand_cvc_3000.py ├── examples │ ├── concurrent_tasks.py │ ├── new_device_template.py │ └── notebooks │ │ ├── heidolph_hei100.ipynb │ │ ├── ika_rct_digital.ipynb │ │ ├── ika_ret_control_visc.ipynb │ │ ├── ika_rv_10.ipynb │ │ ├── julabo_cf41.ipynb │ │ └── vacuubrand_cvc3000.ipynb ├── exceptions.py ├── models.py ├── parsers.py ├── pytest.ini └── utils │ └── openapi_parser.py ├── README.md ├── docs ├── Makefile ├── README_DOCS.md ├── conf.py ├── contributing.rst ├── data_model.rst ├── images │ ├── _static │ │ ├── flow_diagram_data_from_device.png │ │ ├── flow_diagram_data_to_device.png │ │ ├── logo_200px.png │ │ ├── logo_600px.png │ │ ├── logo_white_200px.png │ │ └── logo_with_text_600px.png │ ├── flow_diagram.ai │ └── logo │ │ └── logo.ai ├── index.rst ├── overview.rst ├── src │ ├── connections.rst │ ├── controllers.rst │ ├── devices.buchi_c815.rst │ ├── devices.buchi_r300.rst │ ├── devices.heidolph_hei_torque_100_precision.rst │ ├── devices.heidolph_rzr_2052_control.rst │ ├── devices.huber_petite_fleur.rst │ ├── devices.idex_mxii.rst │ ├── devices.ika_microstar_75.rst │ ├── devices.ika_rct_digital.rst │ ├── devices.ika_ret_control_visc.rst │ ├── devices.ika_rv10.rst │ ├── devices.julabo_cf41.rst │ ├── devices.rst │ ├── devices.tricontinent_c3000.rst │ ├── devices.vacuubrand_cvc_3000.rst │ ├── exceptions.rst │ ├── main.rst │ └── parsers.rst ├── usage.rst └── utils.rst ├── manuals ├── Vacubrand_CVC3000.pdf ├── buchi_c815_openapi.json ├── buchi_r300_openapi.yaml ├── heidolph_hei_torque_precision.pdf ├── heidolph_rzr_2052_control.pdf ├── huber_thermostats.pdf ├── idex_mx_ii.pdf ├── ika_microstar_75.pdf ├── ika_rct_digital.pdf ├── ika_ret_control_visc.pdf ├── ika_rv10.pdf ├── ika_rv10_heating_bath.pdf ├── julabo_cf41.pdf └── tricontinent_c_series.pdf └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pdf filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | **/__pycache__/ 3 | .vscode/ 4 | *.egg-info/ 5 | *.log 6 | build/ 7 | dist/ 8 | .mypy_cache/ 9 | .pytest_cache/ 10 | venv/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2021] [Cronin Group] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PyLabware/__init__.py: -------------------------------------------------------------------------------- 1 | # Heidolph 2 | from .devices.heidolph_hei_torque_100_precision import HeiTorque100PrecisionStirrer 3 | from .devices.heidolph_rzr_2052_control import RZR2052ControlStirrer 4 | 5 | # Huber 6 | from .devices.huber_petite_fleur import PetiteFleurChiller 7 | 8 | # Buchi 9 | from .devices.buchi_r300 import R300Rotovap 10 | from .devices.buchi_c815 import C815FlashChromatographySystem 11 | 12 | # IDEX 13 | from .devices.idex_mxii import IDEXMXIIValve 14 | 15 | # IKA 16 | from .devices.ika_microstar_75 import Microstar75Stirrer 17 | from .devices.ika_rct_digital import RCTDigitalHotplate 18 | from .devices.ika_ret_control_visc import RETControlViscHotplate 19 | from .devices.ika_rv10 import RV10Rotovap 20 | 21 | # JULABO 22 | from .devices.julabo_cf41 import CF41Chiller 23 | 24 | # Tricontinent 25 | from .devices.tricontinent_c3000 import C3000SyringePump 26 | 27 | # Vacuubrand 28 | from .devices.vacuubrand_cvc_3000 import CVC3000VacuumPump 29 | -------------------------------------------------------------------------------- /PyLabware/devices/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/PyLabware/devices/__init__.py -------------------------------------------------------------------------------- /PyLabware/devices/heidolph_hei_torque_100_precision.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for Heidolph HeiTorque 100 Control overhead stirrer.""" 2 | 3 | import re 4 | 5 | from typing import Any, Optional, Dict, Union 6 | import serial 7 | 8 | 9 | # Core imports 10 | from .. import parsers as parser 11 | from ..controllers import AbstractStirringController, in_simulation_device_returns 12 | from ..exceptions import (PLConnectionError, 13 | PLDeviceInternalError, 14 | PLDeviceReplyError) 15 | from ..models import LabDeviceCommands, ConnectionParameters 16 | 17 | 18 | class HeiTorque100PrecisionStirrerCommands(LabDeviceCommands): 19 | """Collection of command definitions for HeiTorque 100 Control overhead stirrer. 20 | """ 21 | 22 | # ################### Configuration constants ############################# 23 | NO_ERROR = "No Error!" 24 | MOTOR_ERROR = "Motor Error!" 25 | OVERHEAT_ERROR = "Motor Temperature!" 26 | OVERLOAD_ERROR = "Overload!" 27 | MANUAL_STOP_ERROR = "Stopped Manually!" 28 | 29 | # Default name for the HT 100 Precision model replied to T command 30 | DEFAULT_NAME = "HT:100P" 31 | 32 | # ################### Control commands ################################### 33 | # Clear OVERLOAD error 34 | CLEAR_ERROR = {"name": "C", "reply": {"type": str}} 35 | # Get status/error message 36 | GET_STATUS = {"name": "f", "reply": {"type": str, "parser": parser.researcher, "args": [r'FLT:\s(.*!)']}} 37 | # Identify the instrument (Flash the device display) 38 | IDENTIFY = {"name": "M", "reply": {"type": str}} 39 | # Get stirrer name 40 | GET_NAME = {"name": "T", "reply": {"type": str}} 41 | # Stop stirrer 42 | STOP = {"name": "R0000", "reply": {"type": int, "parser": parser.researcher, "args": [r'SET:\s(\d{1,4})']}} 43 | # Set rotation speed (rpm) 44 | SET_SPEED = {"name": "R", "type": int, "check": {"min": 10, "max": 2000}, 45 | "reply": {"type": int, "parser": parser.researcher, "args": [r'SET:\s(\d{1,4})']}} 46 | # Get rotation speed setpoint 47 | GET_SPEED_SET = {"name": "s", "reply": {"type": int, "parser": parser.researcher, "args": [r'SET:\s(\d{1,4})']}} 48 | # Get actual rotation speed 49 | GET_SPEED = {"name": "r", "reply": {"type": int, "parser": parser.researcher, "args": [r'RPM:\s(\d{1,4})']}} 50 | 51 | # Get torque (in Newton millimeter - Nmm) 52 | GET_TORQUE = {"name": "m", "reply": {"type": int, "parser": parser.researcher, "args": [r'NCM:\s(-*?\d{1,4})']}} 53 | # Switch remote control off; motor speed is controlled by knob position. 54 | # WARNING! If this command is issued while the stirrer is rotating, it reads 55 | # out actual knob position & applies according speed, it wouldn't stop! 56 | SET_RMT_OFF = {"name": "D"} 57 | 58 | # ################### Configuration commands ############################# 59 | # Set zero reference for torque value 60 | SET_TORQ_ZERO = {"name": "N", "reply": {"type": str}} 61 | # Speed range I - no effect on HeiTorque100 model 62 | SET_MODE_I = {"name": "B"} 63 | # Speed range II - no effect on HeiTorque100 model 64 | SET_MODE_II = {"name": "A"} 65 | 66 | 67 | class HeiTorque100PrecisionStirrer(AbstractStirringController): 68 | """ 69 | This provides a Python class for the Heidolph Hei-TORQUE 100 Precision 70 | overhead stirrer based on the english section of the original 71 | operation manual 01-005-005-55-4, 15.08.2019. 72 | """ 73 | 74 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 75 | """Default constructor. 76 | """ 77 | 78 | # Load commands from helper class 79 | self.cmd = HeiTorque100PrecisionStirrerCommands 80 | 81 | # connection settings 82 | connection_parameters: ConnectionParameters = {} 83 | connection_parameters["port"] = port 84 | connection_parameters["address"] = address 85 | connection_parameters["baudrate"] = 9600 86 | connection_parameters["bytesize"] = serial.EIGHTBITS 87 | connection_parameters["parity"] = serial.PARITY_NONE 88 | 89 | super().__init__(device_name, connection_mode, connection_parameters) 90 | 91 | # Protocol settings 92 | self.command_terminator = "\r\n" 93 | self.reply_terminator = "\r\n" 94 | self.args_delimiter = "" 95 | 96 | # Internal state variable 97 | # This stirrer lack explicit start/stop commands, so it starts as soon as you set non-zero speed 98 | self._speed_setpoint = 0 99 | self._running = False 100 | 101 | def parse_reply(self, cmd: Dict, reply: Any) -> Any: 102 | """Overloaded base class method to handle regex parsing. 103 | 104 | Args: 105 | reply: Reply from the device. 106 | cmd: Command definition toc heck for reply parsing workflow. 107 | 108 | Returns: 109 | (any): Parsed reply from the device. 110 | """ 111 | 112 | # Handle standard parsing 113 | reply = super().parse_reply(cmd, reply) 114 | # If we parsed with regexp, extract first matched group from Regex match object 115 | if isinstance(reply, re.Match): # type: ignore 116 | if reply is None: 117 | raise PLDeviceReplyError("Regular expression match failed on device reply!") 118 | reply = reply[1] 119 | self.logger.debug("parse_reply()::extracted regex result <%s>", reply) 120 | # Cast the right type 121 | return self.cast_reply_type(cmd, reply) 122 | return reply 123 | 124 | def initialize_device(self): 125 | """Performs device initialization & clear the errors. 126 | """ 127 | 128 | # Blink the screen - visual aid 129 | self.identify() 130 | try: 131 | self.check_errors() 132 | except PLDeviceInternalError: 133 | self.clear_errors() 134 | self.logger.info("Device initialized.") 135 | 136 | def identify(self): 137 | """Blinks the screen. 138 | """ 139 | 140 | self.send(self.cmd.IDENTIFY) 141 | 142 | @in_simulation_device_returns(HeiTorque100PrecisionStirrerCommands.NO_ERROR) 143 | def get_status(self) -> str: 144 | """ Gets device status. 145 | """ 146 | 147 | return self.send(self.cmd.GET_STATUS) 148 | 149 | def check_errors(self): 150 | """Check device for errors & raises PLDeviceInternalError with 151 | appropriate error message. 152 | """ 153 | 154 | status = self.get_status() 155 | if status == self.cmd.OVERHEAT_ERROR: 156 | errmsg = "Device overheat error!" 157 | self.logger.error(errmsg) 158 | raise PLDeviceInternalError(errmsg) 159 | if status == self.cmd.MOTOR_ERROR: 160 | errmsg = "Device motor error!" 161 | self.logger.error(errmsg) 162 | raise PLDeviceInternalError(errmsg) 163 | if status == self.cmd.OVERLOAD_ERROR: 164 | errmsg = "Device overload error!" 165 | self.logger.error(errmsg) 166 | raise PLDeviceInternalError(errmsg) 167 | if status == self.cmd.MANUAL_STOP_ERROR: 168 | errmsg = "Device manual stop error!" 169 | self.logger.error(errmsg) 170 | raise PLDeviceInternalError(errmsg) 171 | 172 | def clear_errors(self): 173 | """Clears device errors. 174 | """ 175 | 176 | self.send(self.cmd.CLEAR_ERROR) 177 | 178 | @in_simulation_device_returns(HeiTorque100PrecisionStirrerCommands.DEFAULT_NAME) 179 | def is_connected(self) -> bool: 180 | """Checks whether device is connected. 181 | """ 182 | 183 | try: 184 | reply = self.send(self.cmd.GET_NAME) 185 | except PLConnectionError: 186 | return False 187 | if self.cmd.DEFAULT_NAME in reply: 188 | return True 189 | self.logger.warning("Device name <%s> doesn't seem to match device model!", reply) 190 | return False 191 | 192 | def is_idle(self) -> bool: 193 | """Checks whether device is idle. 194 | """ 195 | 196 | if not self.is_connected(): 197 | return False 198 | ready = self.get_status() 199 | return ready == self.cmd.NO_ERROR and not self._running 200 | 201 | def start_stirring(self): 202 | """Starts rotation. 203 | """ 204 | 205 | if self._speed_setpoint == 0: 206 | self.logger.warning("Starting device with zero speed makes no effect.") 207 | return 208 | self._running = True 209 | self.set_speed(self._speed_setpoint) 210 | 211 | @in_simulation_device_returns(0) 212 | def stop_stirring(self): 213 | """Stops rotation. 214 | """ 215 | 216 | readback_setpoint = self.send(self.cmd.STOP) 217 | if readback_setpoint != 0: 218 | raise PLDeviceReplyError(f"Error stopping stirrer. Requested setpoint <{self._speed_setpoint}> RPM, " 219 | f"read back setpoint <{readback_setpoint}> RPM") 220 | self._running = False 221 | 222 | @in_simulation_device_returns("{$args[1]}") 223 | def set_speed(self, speed: int): 224 | """Sets rotation speed in rpm. 225 | """ 226 | 227 | # If the stirrer is not running, just update internal variable 228 | if not self._running: 229 | # Check value against limits before updating 230 | self.check_value(self.cmd.SET_SPEED, speed) 231 | self._speed_setpoint = speed 232 | else: 233 | readback_setpoint = self.send(self.cmd.SET_SPEED, speed) 234 | if readback_setpoint != speed: 235 | self.stop() 236 | raise PLDeviceReplyError(f"Error setting stirrer speed. Requested setpoint <{self._speed_setpoint}> " 237 | f"RPM, read back setpoint <{readback_setpoint}> RPM") 238 | self._speed_setpoint = speed 239 | 240 | def get_speed(self) -> int: 241 | """Gets actual rotation speed in rpm. 242 | """ 243 | 244 | return self.send(self.cmd.GET_SPEED) 245 | 246 | def get_speed_setpoint(self) -> int: 247 | """Gets desired rotation speed. 248 | """ 249 | 250 | return self._speed_setpoint 251 | 252 | def get_torque(self) -> int: 253 | """Gets current torque value in Nmm. 254 | """ 255 | 256 | return self.send(self.cmd.GET_TORQUE) 257 | 258 | def calibrate_torque(self): 259 | """Sets current measured torques to zero. 260 | """ 261 | 262 | self.send(self.cmd.SET_TORQ_ZERO) 263 | -------------------------------------------------------------------------------- /PyLabware/devices/heidolph_rzr_2052_control.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for Heidolph RZR 2052 Control overhead stirrer.""" 2 | 3 | import re 4 | 5 | from typing import Any, Optional, Union, Dict 6 | import serial 7 | 8 | 9 | # Core imports 10 | from .. import parsers as parser 11 | from ..controllers import ( 12 | AbstractStirringController, in_simulation_device_returns) 13 | from ..exceptions import (PLConnectionError, 14 | PLDeviceInternalError, 15 | PLDeviceReplyError) 16 | from ..models import LabDeviceCommands, ConnectionParameters 17 | 18 | 19 | class RZR2052ControlStirrerCommands(LabDeviceCommands): 20 | """Collection of command definitions for RZR 2052 Control overhead stirrer.""" 21 | 22 | # ################### Configuration constants ############################# 23 | NO_ERROR = "No Error!" 24 | MOTOR_ERROR = "Motor Error!" 25 | OVERHEAT_ERROR = "Motor Temperature!" 26 | 27 | # ################### Control commands ################################### 28 | # Clear error & restart the motor 29 | RESET = {"name": "C", "reply": {"type": str}} 30 | # Get status/error message 31 | GET_STATUS = {"name": "f", "reply": {"type": str, "parser": parser.researcher, "args": [r'FLT:\s(.*!)']}} 32 | # Stop stirrer 33 | STOP = {"name": "R0", "reply": {"type": int, "parser": parser.researcher, "args": [r'SET:\s(\d{1,4})']}} 34 | # Set rotation speed & start stirrer 35 | SET_SPEED = {"name": "R", "type": int, "check": {"min": 50, "max": 2000}, 36 | "reply": {"type": int, "parser": parser.researcher, "args": [r'SET:\s(\d{1,4})']}} 37 | # Get rotation speed setpoint 38 | GET_SPEED_SET = {"name": "s", "reply": {"type": int, "parser": parser.researcher, "args": [r'SET:\s(\d{1,4})']}} 39 | # Get actual rotation speed 40 | GET_SPEED = {"name": "r", "reply": {"type": int, "parser": parser.researcher, "args": [r'RPM:\s(\d{1,4})']}} 41 | 42 | # Get torque 43 | GET_TORQUE = {"name": "m", "reply": {"type": int, "parser": parser.researcher, "args": [r'NCM:\s(-*?\d{1,4})']}} 44 | # Switch remote control off; motor speed is controlled by knob position. 45 | # Warning! If this command is issued while the stirrer is rotating, it reads out actual knob position & applies according speed, it wouldn't stop! 46 | SET_RMT_OFF = {"name": "D"} 47 | 48 | # ################### Configuration commands ############################# 49 | # Set zero reference for torque 50 | SET_TORQ_ZERO = {"name": "N", "reply": {"type": str}} 51 | # Speed range I - no effect on 2052 model 52 | SET_MODE_I = {"name": "B"} 53 | # Speed range II - no effect on 2052 model 54 | SET_MODE_II = {"name": "A"} 55 | 56 | 57 | class RZR2052ControlStirrer(AbstractStirringController): 58 | """ 59 | This provides a Python class for the Heidolph RZR 2052 Control 60 | overhead stirrer based on the english section of the original 61 | operation manual 01-005-002-95-2 21/10/2011 62 | """ 63 | 64 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 65 | """Default constructor. 66 | """ 67 | 68 | # Load commands from helper class 69 | self.cmd = RZR2052ControlStirrerCommands 70 | 71 | # Connection settings for serial connection 72 | connection_parameters: ConnectionParameters = {} 73 | connection_parameters["port"] = port 74 | connection_parameters["address"] = address 75 | connection_parameters["baudrate"] = 9600 76 | connection_parameters["bytesize"] = serial.EIGHTBITS 77 | connection_parameters["parity"] = serial.PARITY_NONE 78 | 79 | super().__init__(device_name, connection_mode, connection_parameters) 80 | 81 | # Protocol settings 82 | # There's a bug in the device manual - if you set reply terminator to \r, as recommended, 83 | # R command would always set the speed to 2000 84 | self.command_terminator = "\r\n" 85 | self.reply_terminator = "\r\n" 86 | self.args_delimiter = "" 87 | 88 | # Internal state variables 89 | # This stirrer lack explicit start/stop commands, so it starts as soon as you set non-zero speed 90 | self._speed_setpoint = 0 91 | self._running = False 92 | 93 | def parse_reply(self, cmd: Dict, reply: Any) -> Any: 94 | """Overloaded base class method to handle regex parsing. 95 | 96 | Args: 97 | reply: Reply from the device. 98 | cmd: Command definition toc heck for reply parsing workflow. 99 | 100 | Returns: 101 | (any): Parsed reply from the device. 102 | """ 103 | 104 | # This stirrer seems a bit dodgy - it occasionally spits out "SET: 0\r\n" string into the data stream 105 | # and/or duplicates terminators, thus screwing up reply termination detection. 106 | # Handle standard parsing 107 | reply = super().parse_reply(cmd, reply) 108 | # If we parsed with regexp, extract first matched group from Regex match object 109 | if isinstance(reply, re.Match): # type: ignore 110 | if reply is None: 111 | raise PLDeviceReplyError("Regular expression match failed on device reply!") 112 | reply = reply[1] 113 | self.logger.debug("parse_reply()::extracted regex result <%s>", reply) 114 | # Cast the right type 115 | return self.cast_reply_type(cmd, reply) 116 | return reply 117 | 118 | def initialize_device(self): 119 | """Performs device initialization & clear the errors. 120 | """ 121 | 122 | try: 123 | self.check_errors() 124 | except PLDeviceInternalError: 125 | self.clear_errors() 126 | self.logger.info("Device initialized.") 127 | 128 | def get_status(self) -> str: 129 | """ Gets device status. 130 | """ 131 | 132 | return self.send(self.cmd.GET_STATUS) 133 | 134 | def check_errors(self): 135 | """Check device for errors & raises PLDeviceInternalError with 136 | appropriate error message. 137 | """ 138 | 139 | status = self.get_status() 140 | if status == self.cmd.OVERHEAT_ERROR: 141 | errmsg = "Device overheat error!" 142 | self.logger.error(errmsg) 143 | raise PLDeviceInternalError(errmsg) 144 | if status == self.cmd.MOTOR_ERROR: 145 | errmsg = "Device motor error!" 146 | self.logger.error(errmsg) 147 | raise PLDeviceInternalError(errmsg) 148 | 149 | def clear_errors(self): 150 | """Clears device errors. 151 | """ 152 | 153 | self.send(self.cmd.RESET) 154 | 155 | def is_connected(self) -> bool: 156 | """Checks whether device is connected. 157 | """ 158 | 159 | try: 160 | return self.get_status() == self.cmd.NO_ERROR 161 | except PLConnectionError: 162 | return False 163 | 164 | def is_idle(self) -> bool: 165 | """Checks whether device is idle. 166 | """ 167 | 168 | if not self.is_connected(): 169 | return False 170 | ready = self.get_status() 171 | return ready == self.cmd.NO_ERROR and not self._running 172 | 173 | def start_stirring(self): 174 | """Starts rotation. 175 | """ 176 | 177 | if self._speed_setpoint == 0: 178 | self.logger.warning("Starting device with zero speed makes no effect.") 179 | return 180 | self._running = True 181 | self.set_speed(self._speed_setpoint) 182 | 183 | @in_simulation_device_returns(0) 184 | def stop_stirring(self): 185 | """Stops rotation. 186 | """ 187 | 188 | readback_setpoint = self.send(self.cmd.STOP) 189 | if readback_setpoint != 0: 190 | raise PLDeviceReplyError("Error setting stirrer speed. Requested setpoint <{}> RPM, read back setpoint <{}> RPM".format(self._speed_setpoint, readback_setpoint)) 191 | self._running = False 192 | 193 | @in_simulation_device_returns("{$args[1]}") 194 | def set_speed(self, speed: int): 195 | """Sets rotation speed. 196 | """ 197 | 198 | # If the stirrer is not running, just update internal variable 199 | if not self._running: 200 | # Check value against limits before updating 201 | self.check_value(self.cmd.SET_SPEED, speed) 202 | self._speed_setpoint = speed 203 | else: 204 | readback_setpoint = self.send(self.cmd.SET_SPEED, speed) 205 | if readback_setpoint != speed: 206 | self.stop() 207 | raise PLDeviceReplyError("Error setting stirrer speed. Requested setpoint <{}> RPM, read back setpoint <{}> RPM".format(self._speed_setpoint, readback_setpoint)) 208 | self._speed_setpoint = speed 209 | 210 | def get_speed(self) -> int: 211 | """Gets actual rotation speed. 212 | """ 213 | 214 | return self.send(self.cmd.GET_SPEED) 215 | 216 | def get_speed_setpoint(self) -> int: 217 | """Gets desired rotation speed. 218 | """ 219 | 220 | self._speed_setpoint = self.send(self.cmd.GET_SPEED_SET) 221 | return self._speed_setpoint 222 | 223 | def get_torque(self) -> int: 224 | """Gets current torque value. 225 | """ 226 | 227 | # Possible bug here. Stirrer returns 17 while displaying 1.7. Probably, decimal dot is missing from the reply, so this method is of limited utility. 228 | return self.send(self.cmd.GET_TORQUE) 229 | 230 | def calibrate_torque(self): 231 | """Sets current measured torques to zero. 232 | """ 233 | 234 | self.send(self.cmd.SET_TORQ_ZERO) 235 | -------------------------------------------------------------------------------- /PyLabware/devices/huber_petite_fleur.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for Huber Petite Fleur chiller.""" 2 | 3 | from time import sleep 4 | from typing import Tuple, Optional, Union 5 | import serial 6 | 7 | # Core import 8 | import PyLabware.parsers as parser 9 | from PyLabware.controllers import ( 10 | AbstractTemperatureController, in_simulation_device_returns) 11 | from PyLabware.exceptions import (PLConnectionError, 12 | PLDeviceCommandError, 13 | PLDeviceReplyError) 14 | from PyLabware.models import LabDeviceCommands, ConnectionParameters 15 | 16 | 17 | class PetiteFleurChillerCommands(LabDeviceCommands): 18 | """Collection of command definitions for Huber PetiteFleur chiller.""" 19 | 20 | # ################### Configuration constants ############################# 21 | DEFAULT_NAME = "Huber device" 22 | 23 | # Default prefix for every command 24 | COMMAND_PREFIX = "{M" 25 | 26 | STATUSES = [ 27 | "Temperature control operating mode: ", 28 | "Circulation operating mode: ", 29 | "Refrigerator compressor mode: ", 30 | "Temperature control mode: ", 31 | "Circulating Pump: ", 32 | "Cooling power available: ", 33 | "KeyLock: ", 34 | "PID parameter set: ", 35 | "Error detected: ", 36 | "Warning message detected: ", 37 | "Mode for setting the internal temperature(0X08): ", 38 | "Mode for setting the external temperature(0X09): ", 39 | "DV E-grade: ", 40 | "Power failure: ", 41 | "Freeze protection: " 42 | ] 43 | 44 | # Control Commands 45 | # The string is actually an hex from -15111 to 50000 (in cent of °C) 46 | SET_TEMP_SP = {"name": "{M00", "type": str, "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 47 | # The string is actually an hex from -15111 to 50000 (in cent of °C) 48 | GET_TEMP_SP = {"name": "{M00****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 49 | # The string is actually an hex from -15111 to 50000 (in cent of °C) 50 | GET_TEMP_BATH = {"name": "{M01****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 51 | GET_PUMP_PRESSURE = {"name": "{M03****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 52 | GET_ERRORS = {"name": "{M05****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 53 | GET_WARNINGS = {"name": "{M06****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 54 | GET_PROCESS_TEMP = {"name": "{M07****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 55 | GET_STATUS = {"name": "{M0A****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 56 | STOP_TEMP_CONTROL = {"name": "{M140000", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 57 | START_TEMP_CONTROL = {"name": "{M140001", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 58 | # Possible values - 0, 1, 2. It seems without any effect 59 | SET_PUMP_MODE = {"name": "{M15", "type": str, "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 60 | STOP_CIRCULATOR = {"name": "{M160000", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 61 | START_CIRCULATOR = {"name": "{M160001", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 62 | 63 | # Temperature Ramping Commands (probably not implemented in PetiteFleur firmware) 64 | START_TEMPERATURE_CTRL = {"name": "{M58", "type": str, 65 | "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 66 | SET_RAMP_DURATION = {"name": "{M59", "type": str, "reply": {"type": str, "parser": parser.slicer, 67 | "args": [4, 8]}} # from 0000 -> FFFF (65535) seconds 68 | START_RAMP = {"name": "{M5A", "type": str, "reply": {"type": str, "parser": parser.slicer, "args": [4, 69 | 8]}} # associated with the target temperature in 16 bit hex 70 | GET_RAMP_TEMP = {"name": "{M5A****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 71 | GET_RAMP_TIME = {"name": "{M59****", "reply": {"type": str, "parser": parser.slicer, "args": [4, 8]}} 72 | # Extras 73 | KEY_LOCK = {"name": "{M17", "type": str, "reply": {"type": str}} # Locks the manual interface in the system with 1 74 | 75 | 76 | class PetiteFleurChiller(AbstractTemperatureController): 77 | """ 78 | This provides a Python class for the Huber Petite Fleur 79 | chiller based on the the original operation manual 80 | V1.8.0en/06.10.17 81 | """ 82 | 83 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 84 | """Default constructor. 85 | """ 86 | 87 | self.cmd = PetiteFleurChillerCommands 88 | # serial settings 89 | # all settings are at default 90 | connection_parameters: ConnectionParameters = {} 91 | connection_parameters["port"] = port 92 | connection_parameters["address"] = address 93 | connection_parameters["baudrate"] = 9600 94 | connection_parameters["bytesize"] = serial.EIGHTBITS 95 | connection_parameters["parity"] = serial.PARITY_NONE 96 | connection_parameters["command_delay"] = 1.0 97 | 98 | super().__init__(device_name, connection_mode, connection_parameters) 99 | # Protocol settings 100 | self.command_terminator = "\r\n" 101 | self.reply_terminator = "\r\n" 102 | self.args_delimiter = "" 103 | 104 | def initialize_device(self): 105 | """ This chiller doesn't need/have any initialization. 106 | """ 107 | 108 | def is_connected(self) -> bool: 109 | """Tries to get chiller status & compares it to the template value. 110 | """ 111 | 112 | try: 113 | status = self.get_status() 114 | except PLConnectionError: 115 | return False 116 | return len(status) == len(self.cmd.STATUSES) 117 | 118 | def is_idle(self) -> bool: 119 | """Checks whether the chiller is running. 120 | #TODO Probably rather has to be done by checking device status. 121 | """ 122 | 123 | if not self.is_connected(): 124 | return False 125 | p = self.get_pump_pressure() 126 | return p < 5 127 | 128 | def get_errors(self): 129 | """ Not implemented yet. #TODO 130 | """ 131 | 132 | raise NotImplementedError 133 | 134 | def clear_errors(self): 135 | """ Not implemented yet. #TODO 136 | """ 137 | 138 | raise NotImplementedError 139 | 140 | def check_errors(self): 141 | """ Not implemented yet. #TODO 142 | """ 143 | 144 | raise NotImplementedError 145 | 146 | def temp_transform(self, temp) -> float: 147 | """Returns the temperature transformed into appropriate number: 148 | 16 bit signed integer. 149 | """ 150 | 151 | res = temp & 0b0111111111111111 152 | if res == temp: 153 | return float(res) / 100 154 | return float(res - 0X8000) / 100 155 | 156 | def start_temperature_regulation(self): 157 | """Starts the chiller. 158 | """ 159 | 160 | # start circulation 161 | t = self.send(self.cmd.START_TEMP_CONTROL) 162 | # The 10 + 5 s delay is needed because during the start of the machine no other command 163 | # should be allowed, otherwise silent crash of the system without answer occurs 164 | sleep(10) 165 | # start temperature control 166 | p = self.send(self.cmd.START_CIRCULATOR) 167 | sleep(5) 168 | return bool(int(p and t)) 169 | 170 | @in_simulation_device_returns('0') 171 | def stop_temperature_regulation(self): 172 | """Stops the chiller. 173 | """ 174 | 175 | # stop temperature control 176 | p = self.send(self.cmd.STOP_CIRCULATOR) 177 | # stop circulation 178 | t = self.send(self.cmd.STOP_TEMP_CONTROL) 179 | return int(p and t) == 0 180 | 181 | @in_simulation_device_returns("{$args[1]}") 182 | def set_temperature(self, temperature: float, sensor: int = 0): 183 | """Sets the target temperature of the chiller. 184 | 185 | Args: 186 | temperature (float): Temperature setpoint in °C. 187 | sensor (int): Specify which temperature probe the setpoint applies to. 188 | This device has one common setpoint temperature shared by the external and internal probe. 189 | Thus, the sensor variable has no effect here. 190 | """ 191 | 192 | # setting the setpoint 193 | if -151 <= temperature <= 327: 194 | temperature = int(temperature * 100) 195 | temperature = temperature & 0xFFFF 196 | readback_temp = self.send(self.cmd.SET_TEMP_SP, "{:04X}".format(temperature)) 197 | if readback_temp is None: 198 | raise PLDeviceReplyError(f"Error setting temperature. Requested setpoint <{temperature}>, read back setpoint <{readback_temp}>") 199 | else: 200 | raise PLDeviceCommandError("Temperature value OUT OF RANGE! \n") 201 | 202 | def get_temperature(self, sensor: int = 0) -> float: 203 | """Reads the current temperature of the bath 204 | 205 | Args: 206 | sensor (int): Specify which temperature probe the setpoint applies to. 207 | This device has one common setpoint temperature shared by the external and internal probe. 208 | Thus, the sensor variable has no effect here. 209 | """ 210 | 211 | answer = self.send(self.cmd.GET_TEMP_BATH) 212 | return self.temp_transform(int(answer, base=16)) 213 | 214 | def get_temperature_setpoint(self, sensor: int = 0) -> float: 215 | """Reads the current temperature setpoint. 216 | 217 | Args: 218 | sensor (int): Specify which temperature probe the setpoint applies to. 219 | This device has one common setpoint temperature shared by the external and internal probe. 220 | Thus, the sensor variable has no effect here. 221 | """ 222 | 223 | answer = self.send(self.cmd.GET_TEMP_SP) 224 | return self.temp_transform(int(answer, base=16)) 225 | 226 | # It seems it doesn't work although the manual says it should 227 | def ramp_temperature(self, end_temperature: float, time: int): 228 | """ 229 | Sets the duration for a temperature ramp in seconds. 230 | Range is -32767...32767s where negative values cancel the 231 | ramp. Maximum ramp is a tad over 9 hours. 232 | """ 233 | 234 | # setting the setpoint 235 | if -32767 <= time <= 32767: 236 | ramp_duration_hex = "{:04X}".format(time & 0xFFFF) # convert to two's complement hex string 237 | reply = self.send(self.cmd.SET_RAMP_DURATION, ramp_duration_hex) 238 | if (reply is not None) and (-151 <= end_temperature <= 327): 239 | end_temperature = int(end_temperature * 100) # convert to appropriate decimal format 240 | end_temperature_hex = "{:04X}".format(end_temperature & 0xFFFF) # convert to two's complement hex string 241 | self.send(self.cmd.START_RAMP, end_temperature_hex) 242 | else: 243 | raise PLDeviceCommandError('The requested setpoint is out of range!') 244 | else: 245 | raise PLDeviceCommandError('The requested duration is out of range!') 246 | 247 | def get_ramp_details(self) -> Tuple[int, float]: 248 | """Get remaining time and target temperature for the ramp. 249 | """ 250 | 251 | rem_time = self.send(self.cmd.GET_RAMP_TIME) 252 | rem_time = int(rem_time, base=16) 253 | targ_temp = self.send(self.cmd.GET_RAMP_TEMP) 254 | targ_temp = int(targ_temp, base=16) 255 | return rem_time, self.temp_transform(targ_temp) 256 | 257 | def start_temp_ctrl(self, program: str) -> int: 258 | """Starts the temperature control program input from 0001 -> 0010 259 | """ 260 | 261 | choice = self.send(self.cmd.START_TEMPERATURE_CTRL, program) 262 | choice = int(choice, base=16) 263 | return choice 264 | 265 | def get_status(self) -> str: 266 | """Returns the status of the chiller. 267 | """ 268 | 269 | s = self.send(self.cmd.GET_STATUS) 270 | return '{:015b}'.format(int(s, 16) & 0b111111111111111) 271 | 272 | def interpret_status(self, status_string: str) -> str: 273 | """Interprets the status string to return human-readable status 274 | """ 275 | 276 | ret = "" 277 | ans = {'0': 'INACTIVE', '1': 'ACTIVE'} 278 | p7 = {'0': 'Expert Mode', '1': 'Automatic Mode'} 279 | p13 = {'1': 'No Failure', '0': 'System restarted'} 280 | p5_8_9 = {'0': 'NO', '1': 'YES'} 281 | count = 0 282 | for i in status_string: 283 | if count == 7: 284 | ret += self.cmd.STATUSES[count] + p7[i] + "\n" 285 | elif count in (5, 8, 9): 286 | ret += self.cmd.STATUSES[count] + p5_8_9[i] + "\n" 287 | elif count == 13: 288 | ret += self.cmd.STATUSES[count] + p13[i] + "\n" 289 | else: 290 | ret += self.cmd.STATUSES[count] + ans[i] + "\n" 291 | count += 1 292 | return ret 293 | 294 | def get_pump_pressure(self) -> int: 295 | """Returns the pump pressure (can be used as measure of the pump activity). 296 | """ 297 | 298 | reply = self.send(self.cmd.GET_PUMP_PRESSURE) 299 | return int(reply, base=16) - 1000 300 | 301 | def set_circulator_control(self, pump_mode: int): 302 | """Sets the compressor control mode. 303 | """ 304 | 305 | self.send(self.cmd.SET_PUMP_MODE, "{:04X}".format(pump_mode & 0XFFFF)) 306 | -------------------------------------------------------------------------------- /PyLabware/devices/idex_mxii.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for IDEX MX II series six-port sample injection valve.""" 2 | 3 | from typing import Optional, Union 4 | import time 5 | import serial 6 | 7 | # Core imports 8 | from ..controllers import AbstractDistributionValve, in_simulation_device_returns 9 | from ..exceptions import (PLConnectionError, 10 | PLDeviceCommandError, 11 | PLDeviceInternalError, 12 | PLConnectionTimeoutError) 13 | from ..models import LabDeviceCommands, ConnectionParameters 14 | 15 | 16 | class IDEXMXIIValveCommands(LabDeviceCommands): 17 | """Collection of command definitions for for IDEX valve controller USB protocol. 18 | """ 19 | # ########################## Constants ################################## 20 | # Status codes 21 | STATUS_CODES = { 22 | "*": "Busy", 23 | "44": "Data CRC error.", 24 | "55": "Data integrity error.", 25 | "66": "Valve positioning error.", 26 | "77": "Valve configuration error or command error.", 27 | "88": "Non-volatile memory error.", 28 | "99": "Valve cannot be homed." 29 | } 30 | # Separate literal for busy status, for ease of manipulation 31 | STATUS_BUSY = "*" 32 | 33 | # Command modes for external input 34 | COMMAND_MODES = { 35 | 1: "Level logic", 36 | 2: "Single pulse logic", 37 | 3: "BCD logic", 38 | 4: "Inverted BCD logic", 39 | 5: "Dual pulse logic" 40 | } 41 | 42 | # Serial baudrates 43 | UART_BAUDRATES = { 44 | 1: 9600, 45 | 2: 19200, 46 | 3: 38400, 47 | 4: 57600 48 | } 49 | 50 | # ################### Control commands ################################### 51 | # Move to position 52 | # TODO think about casting to int & formatting 53 | MOVE_TO_POSITION = {"name": "P", "type": str, "reply": {"type": str}} 54 | # Move to home position 55 | MOVE_HOME = {"name": "M", "reply": {"type": str}} 56 | # Get status - current valve position or error code if any 57 | GET_STATUS = {"name": "S", "reply": {"type": str}} 58 | # Get last error code - not sure what's the difference between this one and the one above 59 | GET_ERROR = {"name": "E", "reply": {"type": int}} 60 | 61 | # ################### Configuration commands ############################# 62 | # Set valve profile 63 | # Note: The new operational mode becomes active after 64 | # driver board reset. Invalid operational mode will cause error 77 (valve 65 | # configuration error). 66 | SET_VALVE_PROFILE = {"name": "O", "type": int, "check": {"min": 0, "max": 0xFF}} 67 | # Get valve profile 68 | GET_VALVE_PROFILE = {"name": "Q", "reply": {"type": int}} 69 | # Set new I2C address. Only even numbers. 70 | SET_I2C_ADDRESS = {"name": "N", "type": int, "check": {"min": 0x0E, "max": 0xFE}} 71 | # Set valve command mode 72 | SET_CMD_MODE = {"name": "F", "type": int, "check": {"values": COMMAND_MODES}} 73 | # Get valve command mode 74 | GET_CMD_MODE = {"name": "D", "type": int, "reply": {"type": int}} 75 | # Set baudrate 76 | SET_BAUDRATE = {"name": "X", "type": int, "check": {"values": UART_BAUDRATES}} 77 | # Get FW revision 78 | GET_FW_REV = {"name": "R", "reply": {"type": int}} 79 | 80 | 81 | class IDEXMXIIValve(AbstractDistributionValve): 82 | """Two-position IDEX MX Series II HPLC valve.""" 83 | 84 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 85 | """Default constructor. 86 | """ 87 | 88 | # Load commands from helper class 89 | self.cmd = IDEXMXIIValveCommands 90 | # Connection settings 91 | connection_parameters: ConnectionParameters = {} 92 | connection_parameters["port"] = port 93 | connection_parameters["address"] = address 94 | connection_parameters["baudrate"] = 19200 95 | connection_parameters["bytesize"] = serial.EIGHTBITS 96 | connection_parameters["parity"] = serial.PARITY_NONE 97 | 98 | super().__init__(device_name, connection_mode, connection_parameters) 99 | 100 | # Protocol settings 101 | self.command_terminator = "\r" 102 | self.reply_terminator = "\r" 103 | self.args_delimiter = "" 104 | 105 | def initialize_device(self): 106 | """Not supported on this device. 107 | """ 108 | 109 | @in_simulation_device_returns("01") 110 | def is_connected(self) -> bool: 111 | """Checks if device is connected. 112 | """ 113 | 114 | try: 115 | status = self.send(self.cmd.GET_FW_REV) 116 | # Should return integer 117 | if not status: 118 | return False 119 | except PLConnectionError: 120 | return False 121 | return True 122 | 123 | def is_idle(self): 124 | """Checks whether device is idle. 125 | """ 126 | return self.get_status() != self.cmd.STATUS_BUSY 127 | 128 | def get_status(self): 129 | """Returns device status. 130 | """ 131 | return self.send(self.cmd.GET_STATUS) 132 | 133 | def check_errors(self): 134 | """Check device for errors & raises PLDeviceInternalError with 135 | appropriate error message. 136 | """ 137 | 138 | status = self.get_status() 139 | if status in self.cmd.STATUS_CODES and status != self.cmd.STATUS_BUSY: 140 | errmsg = self.cmd.STATUS_CODES[status] 141 | self.logger.error(errmsg) 142 | raise PLDeviceInternalError(errmsg) 143 | 144 | def clear_errors(self): 145 | """Not supported on this device. 146 | """ 147 | 148 | def start(self): 149 | """Not supported on this device. 150 | """ 151 | 152 | def stop(self): 153 | """Not supported on this device. 154 | """ 155 | 156 | def move_home(self): 157 | """Move valve to home position. 158 | """ 159 | 160 | self.send(self.cmd.MOVE_HOME) 161 | 162 | def set_valve_position(self, position: int): 163 | """Move value to specified position. 164 | Position 1 corresponds to the home position, i.e. injected sample goes 165 | to the loop and eluent to waste. 166 | Position 2 corresponds usually represents the beginning of acquisition 167 | where sample in the loop goes to analysis. 168 | """ 169 | 170 | # This device replies \r if all OK, or nothing if the command is wrong 171 | # We need to distinguish that from lost connection 172 | # Don't forget zero padding 173 | try: 174 | self.send(self.cmd.MOVE_TO_POSITION, f"{position:02d}") 175 | except PLConnectionTimeoutError: 176 | if self.is_connected: 177 | raise PLDeviceCommandError(f"Wrong valve position {position}") 178 | else: 179 | raise 180 | 181 | def get_valve_position(self): 182 | """ Gets current valve position. 183 | """ 184 | return self.get_status() 185 | 186 | def sample(self, seconds: int): 187 | """Move valve to position 2 for `seconds`, then switch back to 1. 188 | 189 | Args: 190 | seconds (int): Number of seconds to stay in position 2. 191 | """ 192 | 193 | self.set_valve_position(2) 194 | time.sleep(seconds) 195 | self.set_valve_position(1) 196 | -------------------------------------------------------------------------------- /PyLabware/devices/ika_microstar_75.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for IKA Microstar 75 overhead stirrer.""" 2 | 3 | from typing import Optional, Union 4 | import serial 5 | 6 | # Core imports 7 | from .. import parsers as parser 8 | from ..controllers import AbstractStirringController, in_simulation_device_returns 9 | from ..exceptions import PLConnectionError 10 | from ..models import LabDeviceCommands, ConnectionParameters 11 | 12 | 13 | class Microstar75StirrerCommands(LabDeviceCommands): 14 | """Collection of command definitions for Microstar 75 overhead stirrer. 15 | """ 16 | 17 | # ########################## Constants ################################## 18 | # Default reply to GET_NAME command 19 | DEFAULT_NAME = "Microstar C" 20 | ROTATION_DIRECTIONS = {"IN_MODE_1": "CW", "IN_MODE_2": "CCW"} 21 | 22 | # ################### Control commands ################################### 23 | # Get device name 24 | GET_NAME = {"name": "IN_NAME", "reply": {"type": str}} 25 | 26 | # Get current stirring speed 27 | GET_SPEED = {"name": "IN_PV_4", "reply": {"type": int, "parser": parser.slicer, "args": [-2]}} 28 | # Get torque value 29 | GET_TORQUE = {"name": "IN_PV_5", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 30 | 31 | # Get stirring speed setpoint 32 | GET_SPEED_SET = {"name": "IN_SP_4", "reply": {"type": int, "parser": parser.slicer, "args": [-2]}} 33 | 34 | # Set stirring speed 35 | SET_SPEED = {"name": "OUT_SP_4", "type": int, "check": {"min": 30, "max": 2000}} 36 | 37 | # Start the stirrer 38 | START = {"name": "START_4"} 39 | # Stop the stirrer 40 | STOP = {"name": "STOP_4"} 41 | 42 | # ################### Configuration commands ############################# 43 | # Get internal Pt1000 reading 44 | GET_INT_PT1000 = {"name": "IN_PV_3", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 45 | # Get torque limit value 46 | GET_TORQUE_LIMIT = {"name": "IN_SP_5", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 47 | # Set torque limit value 48 | SET_TORQUE_LIMIT = {"name": "OUT_SP_5", "type": int} 49 | # Get speed limit value 50 | GET_SPEED_LIMIT = {"name": "IN_SP_6", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 51 | # Set speed limit value 52 | SET_SPEED_LIMIT = {"name": "OUT_SP_6", "type": int, "check": {"min": 30, "max": 2000}} 53 | # Get safety speed value 54 | GET_SPEED_SAFETY = {"name": "IN_SP_8", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 55 | # Get safety speed value 56 | SET_SPEED_SAFETY = {"name": "OUT_SP_8"} 57 | # Get rotation direction 58 | GET_ROTATION_DIR = {"name": "IN_MODE", "reply": {"type": str, "parser": parser.slicer, "args": [-1]}} 59 | # Set rotation direction CW 60 | SET_ROTATION_DIR_CW = {"name": "OUT_MODE_1", "reply": {"type": str}} 61 | # Set rotation direction CW 62 | SET_ROTATION_DIR_CCW = {"name": "OUT_MODE_2", "reply": {"type": str}} 63 | # Reset device operation mode 64 | RESET = {"name": "RESET"} 65 | 66 | 67 | class Microstar75Stirrer(AbstractStirringController): 68 | """ 69 | This provides a Python class for the Microstar 75 overhead stirrer 70 | based on the english section of the original operation manual 71 | 20000008217b_EN_IKA MICROSTAR control_072019_web 72 | """ 73 | 74 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 75 | """Default constructor. 76 | """ 77 | 78 | # Load commands from helper class 79 | self.cmd = Microstar75StirrerCommands 80 | 81 | # Connection settings 82 | connection_parameters: ConnectionParameters = {} 83 | connection_parameters["port"] = port 84 | connection_parameters["address"] = address 85 | connection_parameters["baudrate"] = 9600 86 | connection_parameters["bytesize"] = serial.SEVENBITS 87 | connection_parameters["parity"] = serial.PARITY_EVEN 88 | connection_parameters["command_delay"] = 0.3 89 | 90 | super().__init__(device_name, connection_mode, connection_parameters) 91 | 92 | # Protocol settings 93 | self.command_terminator = "\r\n" 94 | self.reply_terminator = "\r\n" 95 | self.args_delimiter = " " 96 | 97 | # Internal speed variable 98 | # This device has a bug - if you set speed when the stirrer is off 99 | # It wil set the speed to zero upon start 100 | self._speed_setpoint = 0 101 | 102 | # Running flag - this device doesn't have a status check command 103 | self._running = False 104 | 105 | def initialize_device(self): 106 | """Performs device initialization. Updates internal variable with the 107 | actual speed setpoint from the device. 108 | """ 109 | 110 | self.get_speed_setpoint() 111 | self.logger.info("Device initialized.") 112 | 113 | def reset(self): 114 | """Switches back to local control, according to the manual 115 | """ 116 | 117 | self.send(self.cmd.RESET) 118 | 119 | @in_simulation_device_returns(Microstar75StirrerCommands.DEFAULT_NAME) 120 | def is_connected(self) -> bool: 121 | """Checks whether device is connected. 122 | """ 123 | 124 | try: 125 | reply = self.send(self.cmd.GET_NAME) 126 | except PLConnectionError: 127 | return False 128 | return reply == self.cmd.DEFAULT_NAME 129 | 130 | def is_idle(self) -> bool: 131 | """Checks whether device is ready. 132 | """ 133 | 134 | if not self.is_connected(): 135 | return False 136 | return not self._running 137 | 138 | def get_status(self): 139 | """Not supported on this device. 140 | """ 141 | 142 | def check_errors(self): 143 | """Not supported on this device. 144 | """ 145 | 146 | def clear_errors(self): 147 | """Not supported on this device. 148 | """ 149 | 150 | def start_stirring(self): 151 | """Starts rotation. 152 | """ 153 | 154 | self.send(self.cmd.START) 155 | self.set_speed(self._speed_setpoint) 156 | self._running = True 157 | 158 | def stop_stirring(self): 159 | """Stops rotation. 160 | """ 161 | 162 | self.send(self.cmd.STOP) 163 | self._running = False 164 | 165 | def set_speed(self, speed: int): 166 | """Sets rotation speed. 167 | """ 168 | 169 | self.send(self.cmd.SET_SPEED, speed) 170 | self._speed_setpoint = speed 171 | 172 | def get_speed(self) -> int: 173 | """Gets actual rotation speed. 174 | """ 175 | 176 | return self.send(self.cmd.GET_SPEED) 177 | 178 | def get_speed_setpoint(self) -> int: 179 | """Gets desired rotation speed. 180 | """ 181 | 182 | self._speed_setpoint = self.send(self.cmd.GET_SPEED_SET) 183 | return self._speed_setpoint 184 | 185 | def get_rotation_direction(self) -> str: 186 | """Gets current rotation direction. 187 | """ 188 | 189 | reply = self.send(self.cmd.GET_ROTATION_DIR) 190 | return self.cmd.ROTATION_DIRECTIONS[reply] 191 | 192 | def set_rotation_direction(self, direction: str = "CW"): 193 | """Sets desired rotation direction - CW or CCW. 194 | """ 195 | 196 | direction = direction.upper() 197 | if direction not in self.cmd.ROTATION_DIRECTIONS.values(): 198 | self.logger.error("Rotation direction can be only CW or CCW") 199 | return 200 | if self.get_speed() != 0: 201 | self.logger.warning("Direction change is allowed only when stirrer is off.") 202 | return 203 | if direction == "CW": 204 | self.send(self.cmd.SET_ROTATION_DIR_CW) 205 | else: 206 | self.send(self.cmd.SET_ROTATION_DIR_CCW) 207 | 208 | def change_rotation_direction(self): 209 | """Swaps current rotation direction. 210 | """ 211 | 212 | direction = self.get_rotation_direction() 213 | self.stop() 214 | if direction == "CW": 215 | self.send(self.cmd.SET_ROTATION_DIR_CCW) 216 | else: 217 | self.send(self.cmd.SET_ROTATION_DIR_CW) 218 | self.start() 219 | -------------------------------------------------------------------------------- /PyLabware/devices/ika_rct_digital.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for IKA RCT Digital stirring hotplate.""" 2 | 3 | from typing import Optional, Union 4 | import serial 5 | 6 | # Core imports 7 | from .. import parsers as parser 8 | from ..controllers import AbstractHotplate, in_simulation_device_returns 9 | from ..exceptions import PLConnectionError, PLDeviceCommandError 10 | from ..models import LabDeviceCommands, ConnectionParameters 11 | 12 | 13 | class RCTDigitalHotplateCommands(LabDeviceCommands): 14 | """Collection of command definitions for for IKA RCT Digital stirring hotplate. 15 | """ 16 | 17 | # ########################## Constants ################################## 18 | # Default reply to GET_NAME command 19 | DEFAULT_NAME = "RCT digital" 20 | TEMP_SENSORS = {0: "INTERNAL", 1: "EXTERNAL"} 21 | 22 | # ################### Control commands ################################### 23 | # Get device name 24 | GET_NAME = {"name": "IN_NAME", "reply": {"type": str}} 25 | # Get external sensor temperature 26 | GET_TEMP_EXT = {"name": "IN_PV_1", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 27 | # Get internal hotplate sensor temperature 28 | GET_TEMP = {"name": "IN_PV_2", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 29 | # Get current stirring speed 30 | GET_SPEED = {"name": "IN_PV_4", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 31 | # Get viscosity trend value 32 | GET_VISC = {"name": "IN_PV_5", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 33 | # Get temperature setpoint 34 | GET_TEMP_SET = {"name": "IN_SP_1", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 35 | # Get safety temperature setpoint 36 | GET_SAFE_TEMP_SET = {"name": "IN_SP_3", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 37 | # Get stirring speed setpoint 38 | GET_SPEED_SET = {"name": "IN_SP_4", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 39 | # Set temperature 40 | SET_TEMP = {"name": "OUT_SP_1", "type": int, "check": {"min": 20, "max": 310}} 41 | # Set stirring speed 42 | SET_SPEED = {"name": "OUT_SP_4", "type": int, "check": {"min": 0, "max": 1500}} 43 | # Start the heater 44 | START_HEAT = {"name": "START_1"} 45 | # Stop the heater 46 | STOP_HEAT = {"name": "STOP_1"} 47 | # Start the stirrer 48 | START_STIR = {"name": "START_4"} 49 | # Stop the stirrer 50 | STOP_STIR = {"name": "STOP_4"} 51 | 52 | # ################### Configuration commands ############################# 53 | # Set device operation mode A (normal) 54 | SET_MODE_A = {"name": "SET_MODE_A"} 55 | # Set device operation mode B (refer to the manual) 56 | SET_MODE_B = {"name": "SET_MODE_B"} 57 | # Set device operation mode D (refer to the manual) 58 | SET_MODE_D = {"name": "SET_MODE_D"} 59 | # Reset device operation mode 60 | RESET = {"name": "RESET"} 61 | 62 | 63 | class RCTDigitalHotplate(AbstractHotplate): 64 | """ 65 | This provides a Python class for the IKA RCT Digital hotplate 66 | based on the english section of the original 67 | operation manual 201811_IKAPlate-Lab_A1_25002139a. 68 | """ 69 | 70 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 71 | """Default constructor 72 | """ 73 | 74 | # Load commands from helper class 75 | self.cmd = RCTDigitalHotplateCommands 76 | 77 | # Connection settings 78 | connection_parameters: ConnectionParameters = {} 79 | connection_parameters["port"] = port 80 | connection_parameters["address"] = address 81 | connection_parameters["baudrate"] = 9600 82 | connection_parameters["bytesize"] = serial.SEVENBITS 83 | connection_parameters["parity"] = serial.PARITY_EVEN 84 | 85 | super().__init__(device_name, connection_mode, connection_parameters) 86 | 87 | # Protocol settings 88 | self.command_terminator = " \r \n" # Note spaces! TODO check whether this is actually important 89 | self.reply_terminator = "\r\n" # No spaces here 90 | self.args_delimiter = " " 91 | 92 | # This device has no command to check status 93 | self._heating = False 94 | self._stirring = False 95 | 96 | def initialize_device(self): 97 | """Set default operation mode & reset. 98 | """ 99 | 100 | self.send(self.cmd.SET_MODE_A) 101 | self.send(self.cmd.RESET) 102 | self.logger.info("Device initialized.") 103 | 104 | @in_simulation_device_returns(RCTDigitalHotplateCommands.DEFAULT_NAME) 105 | def is_connected(self) -> bool: 106 | """ Check if the device is connected via GET_NAME command. 107 | """ 108 | 109 | try: 110 | reply = self.send(self.cmd.GET_NAME) 111 | except PLConnectionError: 112 | return False 113 | return reply == self.cmd.DEFAULT_NAME 114 | 115 | def is_idle(self) -> bool: 116 | """Returns True if no stirring or heating is active. 117 | """ 118 | 119 | if not self.is_connected(): 120 | return False 121 | return not (self._heating or self._stirring) 122 | 123 | def get_status(self): 124 | """Not supported on this device. 125 | """ 126 | 127 | def check_errors(self): 128 | """Not supported on this device. 129 | """ 130 | 131 | def clear_errors(self): 132 | """Not supported on this device. 133 | """ 134 | 135 | def start_temperature_regulation(self): 136 | """Starts heating. 137 | """ 138 | 139 | self.send(self.cmd.START_HEAT) 140 | self._heating = True 141 | 142 | def stop_temperature_regulation(self): 143 | """Stops heating. 144 | """ 145 | 146 | self.send(self.cmd.STOP_HEAT) 147 | self._heating = False 148 | 149 | def start_stirring(self): 150 | """Starts stirring. 151 | """ 152 | 153 | self.send(self.cmd.START_STIR) 154 | self._stirring = True 155 | 156 | def stop_stirring(self): 157 | """Stops stirring. 158 | """ 159 | 160 | self.send(self.cmd.STOP_STIR) 161 | self._stirring = False 162 | 163 | def get_temperature(self, sensor: int = 0) -> float: 164 | """Gets the actual temperature. 165 | 166 | Args: 167 | sensor (int): Specify which temperature probe to read. 168 | """ 169 | 170 | if sensor == 0: 171 | return self.send(self.cmd.GET_TEMP) 172 | elif sensor == 1: 173 | return self.send(self.cmd.GET_TEMP_EXT) 174 | else: 175 | raise PLDeviceCommandError(f"Invalid sensor provided! Allowed values are: {self.cmd.TEMP_SENSORS}") 176 | 177 | def get_temperature_setpoint(self, sensor: int = 0) -> float: 178 | """Reads the current temperature setpoint. 179 | 180 | Args: 181 | sensor (int): Specify which temperature probe the setpoint applies to. 182 | This device uses a shared setpoint for all temperature probes. 183 | Hence, this argument has no effect here. 184 | """ 185 | 186 | return self.send(self.cmd.GET_TEMP_SET) 187 | 188 | def set_temperature(self, temperature: float, sensor: int = 0): 189 | """Sets the desired temperature. 190 | 191 | Args: 192 | temperature (float): Temperature setpoint in °C. 193 | sensor (int): Specify which temperature probe the setpoint applies to. 194 | This device uses a shared setpoint for all temperature probes. 195 | Hence, this argument has no effect here. 196 | """ 197 | 198 | self.send(self.cmd.SET_TEMP, temperature) 199 | 200 | def get_speed(self) -> int: 201 | """Gets the actual stirring speed. 202 | """ 203 | 204 | return self.send(self.cmd.GET_SPEED) 205 | 206 | def get_speed_setpoint(self) -> int: 207 | """Gets desired stirring speed setpoint. 208 | """ 209 | 210 | return self.send(self.cmd.GET_SPEED_SET) 211 | 212 | def set_speed(self, speed: int): 213 | """Sets desired speed. 214 | """ 215 | 216 | self.send(self.cmd.SET_SPEED, speed) 217 | 218 | def get_viscosity_trend(self) -> float: 219 | """Gets current viscosity rend. 220 | """ 221 | 222 | return self.send(self.cmd.GET_VISC) 223 | -------------------------------------------------------------------------------- /PyLabware/devices/ika_ret_control_visc.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for IKA RET Control Visc stirring hotplate.""" 2 | 3 | from typing import Optional, Union 4 | import serial 5 | 6 | # Core imports 7 | from .. import parsers as parser 8 | from ..controllers import AbstractHotplate, in_simulation_device_returns 9 | from ..exceptions import PLConnectionError, PLDeviceCommandError 10 | from ..models import LabDeviceCommands, ConnectionParameters 11 | 12 | 13 | class RETControlViscHotplateCommands(LabDeviceCommands): 14 | """Collection of commands for IKA RET Control Visc stirring hotplate. 15 | """ 16 | 17 | # ########################## Constants ################################## 18 | # Default reply to GET_NAME command 19 | DEFAULT_NAME = "IKARET" 20 | # Temperature probes 21 | TEMP_SENSORS = {0: "INTERNAL", 1: "EXTERNAL", 2: "MEDIUM"} 22 | 23 | # ################### Control commands ################################### 24 | # Get device name 25 | GET_NAME = {"name": "IN_NAME", "reply": {"type": str}} 26 | # Set device name, 6 symbols max 27 | SET_NAME = {"name": "OUT_NAME", "type": str} 28 | # Get internal hotplate sensor temperature 29 | GET_TEMP = {"name": "IN_PV_2", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 30 | # Get external sensor temperature 31 | GET_TEMP_EXT = {"name": "IN_PV_1", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 32 | # Get second external sensor temperature (heat carrier temperature, see the manual) 33 | GET_TEMP_EXT_2 = {"name": "IN_PV_7", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 34 | # Get hotplate safety temperature 35 | GET_TEMP_SAFE = {"name": "IN_PV_3", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 36 | # Get current stirring speed 37 | GET_SPEED = {"name": "IN_PV_4", "reply": {"type": int, "parser": parser.slicer, "args": [-2]}} 38 | # Get viscosity trend value 39 | GET_VISC = {"name": "IN_PV_5", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 40 | # Get pH value 41 | GET_PH = {"name": "IN_PV_80", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 42 | # Get weight value 43 | GET_WEIGHT = {"name": "IN_PV_90", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 44 | # Get temperature setpoint 45 | GET_TEMP_SET = {"name": "IN_SP_2", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 46 | # Get external temperature setpoint 47 | GET_TEMP_EXT_SET = {"name": "IN_SP_1", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 48 | # Get second external temperature setpoint 49 | GET_TEMP_EXT_2_SET = {"name": "IN_SP_7", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 50 | # Get safety temperature setpoint 51 | GET_TEMP_SAFE_SET = {"name": "IN_SP_3", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 52 | # Get stirring speed setpoint 53 | GET_SPEED_SET = {"name": "IN_SP_4", "reply": {"type": int, "parser": parser.slicer, "args": [-2]}} 54 | # Set temperature 55 | SET_TEMP = {"name": "OUT_SP_2", "type": int, "check": {"min": 0, "max": 340}} 56 | # Set external sensor temperature 57 | SET_TEMP_EXT = {"name": "OUT_SP_1", "type": int, "check": {"min": 0, "max": 340}} 58 | # Set second external sensor temperature 59 | SET_TEMP_EXT_2 = {"name": "OUT_SP_7", "type": int, "check": {"min": 20, "max": 340}} 60 | # Set stirring speed 61 | SET_SPEED = {"name": "OUT_SP_4", "type": int, "check": {"min": 50, "max": 1700}} 62 | 63 | # Start the heater 64 | START_HEAT = {"name": "START_1"} 65 | # Stop the heater 66 | STOP_HEAT = {"name": "STOP_1"} 67 | # Start the stirrer 68 | START_STIR = {"name": "START_4"} 69 | # Stop the stirrer 70 | STOP_STIR = {"name": "STOP_4"} 71 | 72 | # ################### Configuration commands ############################# 73 | # Get firmware version 74 | GET_VERSION = {"name": "IN_VERSION", "reply": {"type": str}} 75 | # Reset device operation mode 76 | RESET = {"name": "RESET"} 77 | # Set watchdog fallback temperature 78 | SET_WD_SAFE_TEMP = {"name": "OUT_SP_12@", "type": int, "check": {"min": 0, "max": 340}, 79 | "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 80 | # Set watchdog fallback speed 81 | SET_WD_SAFE_SPEED = {"name": "OUT_SP_42@", "type": int, "check": {"min": 0, "max": 1700}, 82 | "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 83 | # Set timeout (seconds) and enable watchdog mode 1 (switching off on watchdog interrupt) 84 | SET_WD_MODE_1 = {"name": "OUT_SP_WD1@", "type": int, "check": {"min": 20, "max": 1500}} 85 | # Set timeout (seconds) and enable watchdog mode 2 (falling back to safety settings on watchdog interrupt) 86 | SET_WD_MODE_2 = {"name": "OUT_SP_WD2@", "type": int, "check": {"min": 20, "max": 1500}} 87 | # Set safety sensor error timeout 88 | # Get sensor error timeout 89 | GET_SENSOR_TIMEOUT = {"name": "IN_SP_54", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 90 | # Set sensor error timeout 91 | # The user can set a value from 1 to 30 min for this time limit depending on the application. 0 -> disabled 92 | SET_SENSOR_TIMEOUT = {"name": "OUT_SP_54", "type": int, "check": {"min": 0, "max": 30}} 93 | # Set intermittent mode on time, seconds 94 | SET_CYCLE_ON_TIME = {"name": "OUT_SP_55", "type": int, "check": {"min": 10, "max": 600}} 95 | # Set intermittent mode off time, seconds 96 | SET_CYCLE_OFF_TIME = {"name": "OUT_SP_56", "type": int, "check": {"min": 5, "max": 60}} 97 | # Get intermittent mode on time, seconds 98 | GET_CYCLE_ON_TIME = {"name": "IN_SP_55", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 99 | # Get intermittent mode off time, seconds 100 | GET_CYCLE_OFF_TIME = {"name": "IN_SP_56", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 101 | 102 | 103 | class RETControlViscHotplate(AbstractHotplate): 104 | """ 105 | This provides a Python class for the IKA RET Control Visc hotplate 106 | based on the english section of the original 107 | operation manual 20000004159 RET control-visc_042019 108 | """ 109 | 110 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 111 | """Default constructor. 112 | """ 113 | 114 | # Load commands from helper class 115 | self.cmd = RETControlViscHotplateCommands 116 | 117 | # Connection settings 118 | connection_parameters: ConnectionParameters = {} 119 | connection_parameters["port"] = port 120 | connection_parameters["address"] = address 121 | connection_parameters["baudrate"] = 9600 122 | connection_parameters["bytesize"] = serial.SEVENBITS 123 | connection_parameters["parity"] = serial.PARITY_EVEN 124 | 125 | super().__init__(device_name, connection_mode, connection_parameters) 126 | 127 | # Protocol settings 128 | self.command_terminator = "\r\n" 129 | self.reply_terminator = "\r\n" 130 | self.args_delimiter = " " 131 | 132 | # This device has no command to check status 133 | self._heating = False 134 | self._stirring = False 135 | 136 | def initialize_device(self): 137 | """Resets the device. 138 | """ 139 | 140 | self.send(self.cmd.RESET) 141 | 142 | @in_simulation_device_returns(RETControlViscHotplateCommands.DEFAULT_NAME) 143 | def is_connected(self) -> bool: 144 | """Checks whether the device is connected. 145 | """ 146 | 147 | try: 148 | reply = self.send(self.cmd.GET_NAME) 149 | except PLConnectionError: 150 | return False 151 | 152 | if reply == self.cmd.DEFAULT_NAME: 153 | return True 154 | # Check if the stirplate is likely to be an IKA RET Control Visc (based on firmware version) and rename it 155 | elif self.send(self.cmd.GET_VERSION)[0:3] == "110": 156 | self.logger.warning("is_connected()::An IKA RET hotplate with non-standard name has been detected." 157 | "Ensure that the right device is connected!" 158 | "The name will be now reset to default %s", self.cmd.DEFAULT_NAME) 159 | # Set name to default for easier identification 160 | self.send(self.cmd.SET_NAME, self.cmd.DEFAULT_NAME) 161 | return True 162 | else: 163 | return False 164 | 165 | def is_idle(self) -> bool: 166 | """Returns True if no stirring or heating is active. 167 | """ 168 | 169 | if not self.is_connected(): 170 | return False 171 | return not (self._heating or self._stirring) 172 | 173 | def get_status(self): 174 | """Not supported on this device. 175 | """ 176 | 177 | def check_errors(self): 178 | """Not supported on this device. 179 | """ 180 | 181 | def clear_errors(self): 182 | """Not supported on this device. 183 | """ 184 | 185 | def start_temperature_regulation(self): 186 | """Starts heating. 187 | """ 188 | 189 | self.send(self.cmd.START_HEAT) 190 | self._heating = True 191 | 192 | def stop_temperature_regulation(self): 193 | """Stops heating. 194 | """ 195 | 196 | self.send(self.cmd.STOP_HEAT) 197 | self._heating = False 198 | 199 | def start_stirring(self): 200 | """Starts stirring. 201 | """ 202 | 203 | self.send(self.cmd.START_STIR) 204 | self._stirring = True 205 | 206 | def stop_stirring(self): 207 | """Stops stirring. 208 | """ 209 | 210 | self.send(self.cmd.STOP_STIR) 211 | self._stirring = False 212 | 213 | def get_temperature(self, sensor: int = 0) -> float: 214 | """Gets the actual temperature. 215 | 216 | Args: 217 | sensor (int): Specify which temperature probe to read. 218 | """ 219 | 220 | if sensor == 0: 221 | return self.send(self.cmd.GET_TEMP) 222 | elif sensor == 1: 223 | return self.send(self.cmd.GET_TEMP_EXT) 224 | elif sensor == 2: 225 | return self.send(self.cmd.GET_TEMP_EXT_2) 226 | else: 227 | raise PLDeviceCommandError(f"Invalid sensor provided! Allowed values are: {self.cmd.TEMP_SENSORS}") 228 | 229 | def get_temperature_setpoint(self, sensor: int = 0) -> float: 230 | """Gets desired temperature setpoint. 231 | 232 | Args: 233 | sensor (int): Specify which temperature setpoint to read. 234 | """ 235 | 236 | if sensor == 0: 237 | return self.send(self.cmd.GET_TEMP_SET) 238 | elif sensor == 1: 239 | return self.send(self.cmd.GET_TEMP_EXT_SET) 240 | elif sensor == 2: 241 | return self.send(self.cmd.GET_TEMP_EXT_2_SET) 242 | else: 243 | raise PLDeviceCommandError(f"Invalid sensor provided! Allowed values are: {self.cmd.TEMP_SENSORS}") 244 | 245 | def get_safety_temperature(self) -> float: 246 | """Gets safety temperature sensor reading. 247 | """ 248 | 249 | return self.send(self.cmd.GET_TEMP_SAFE) 250 | 251 | def get_safety_temperature_setpoint(self) -> float: 252 | """Gets safety temperature sensor setpoint. 253 | """ 254 | return self.send(self.cmd.GET_TEMP_SAFE_SET) 255 | 256 | def set_temperature(self, temperature: float, sensor: int = 0): 257 | """Sets desired temperature. 258 | 259 | Args: 260 | temperature (float): Temperature setpoint in °C. 261 | sensor (int): Specify which temperature probe the setpoint applies to. 262 | """ 263 | 264 | if sensor == 0: 265 | self.send(self.cmd.SET_TEMP, temperature) 266 | elif sensor == 1: 267 | self.send(self.cmd.SET_TEMP_EXT, temperature) 268 | elif sensor == 2: 269 | self.send(self.cmd.SET_TEMP_EXT_2, temperature) 270 | else: 271 | raise PLDeviceCommandError(f"Invalid sensor provided! Allowed values are: {self.cmd.TEMP_SENSORS}") 272 | 273 | def get_speed(self) -> int: 274 | """Gets current stirring speed. 275 | """ 276 | 277 | return self.send(self.cmd.GET_SPEED) 278 | 279 | def get_speed_setpoint(self) -> int: 280 | """Gets desired speed setpoint. 281 | """ 282 | 283 | return self.send(self.cmd.GET_SPEED_SET) 284 | 285 | def set_speed(self, speed: int): 286 | """Sets the stirring speed. 287 | """ 288 | 289 | self.send(self.cmd.SET_SPEED, speed) 290 | 291 | def get_viscosity_trend(self) -> float: 292 | """Gets current viscosity value. 293 | """ 294 | 295 | return self.send(self.cmd.GET_VISC) 296 | 297 | def get_weight(self) -> float: 298 | """Gets weight - the hotplate has embedded weight sensor. 299 | """ 300 | 301 | return self.send(self.cmd.GET_WEIGHT) 302 | 303 | def get_ph(self) -> float: 304 | """Gets pH value from external probe. 305 | Returns value around 14 with no probe connected. 306 | """ 307 | return self.send(self.cmd.GET_PH) 308 | 309 | def start_watchdog_mode1(self, timeout: int): 310 | """This can't be cleared remotely, requires power cycle. 311 | """ 312 | 313 | self.send(self.cmd.SET_WD_MODE_1, timeout) 314 | 315 | def setup_watchdog_mode2(self, temperature: int, speed: int): 316 | """This can be cleared remotely 317 | """ 318 | 319 | # Set failsafe temperature 320 | self.send(self.cmd.SET_WD_SAFE_TEMP, temperature) 321 | # Set failsafe speed 322 | self.send(self.cmd.SET_WD_SAFE_SPEED, speed) 323 | 324 | def start_watchdog_mode2(self, timeout: int): 325 | """This doesn't display any error as advertised in the manual, just falls back to safety values 326 | """ 327 | 328 | self.send(self.cmd.SET_WD_MODE_2, timeout) 329 | 330 | def stop_watchdog(self): 331 | """Clears mode2 watchdog. 332 | """ 333 | 334 | self.send(self.cmd.SET_WD_MODE_1, 0) 335 | self.send(self.cmd.SET_WD_MODE_2, 0) 336 | -------------------------------------------------------------------------------- /PyLabware/devices/ika_rv10.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for IKA RV10 rotavap.""" 2 | 3 | from typing import Optional, Union 4 | import serial 5 | 6 | # Core imports 7 | from .. import parsers as parser 8 | from ..controllers import AbstractRotavap, in_simulation_device_returns 9 | from ..exceptions import PLConnectionError 10 | from ..models import LabDeviceCommands, ConnectionParameters 11 | 12 | 13 | class RV10RotovapCommands(LabDeviceCommands): 14 | """Collection of command definitions for RV10 rotavap. 15 | """ 16 | 17 | # ########################## Constants ################################## 18 | # Default name. 19 | DEFAULT_NAME = "RV10Digital" 20 | STATUS_CODES = { 21 | "0": "Manual operation", 22 | "1": "Remote operation", 23 | "E01": "No rotation", 24 | "E02": "No communication with the heating bath" 25 | } 26 | 27 | # Heating mediums for the bath 28 | HEATING_MEDIUMS = { 29 | 0: "Oil", 30 | 1: "Water" 31 | } 32 | 33 | # ################### Control commands ################################### 34 | # Get device name 35 | GET_NAME = {"name": "IN_NAME", "reply": {"type": str, "parser": parser.slicer, "args": [None, 11]}} 36 | # Get software version 37 | GET_VERSION = {"name": "IN_SOFTWARE", "reply": {"type": str}} 38 | # The lift commands in the manual are wrong. The working command was found by Sebastian Steiner by e-mailing IKA. 39 | # Move lift up 40 | LIFT_UP = {"name": "OUT_SP_62 1"} 41 | # Move lift down 42 | LIFT_DOWN = {"name": "OUT_SP_63 1"} 43 | # Get rotation speed 44 | GET_SPEED = {"name": "IN_PV_4", "reply": {"type": int, "parser": parser.slicer, "args": [None, -2]}} 45 | # Get rotation speed setpoint 46 | GET_SPEED_SET = {"name": "IN_SP_4", "reply": {"type": int, "parser": parser.slicer, "args": [None, -2]}} 47 | # Set rotation speed 48 | SET_SPEED = {"name": "OUT_SP_4", "type": int, "check": {"min": 0, "max": 280}} 49 | # Start rotation 50 | START_ROTATION = {"name": "START_4"} 51 | # Stop rotation 52 | STOP_ROTATION = {"name": "STOP_4"} 53 | # Start interval mode 54 | START_INTERVAL = {"name": "START_60"} 55 | # Stop interval mode 56 | STOP_INTERVAL = {"name": "STOP_60"} 57 | # Start timer mode 58 | START_TIMER = {"name": "START_61"} 59 | # Stop timer mode 60 | STOP_TIMER = {"name": "STOP_61"} 61 | # Get bath temperature 62 | GET_TEMP = {"name": "IN_PV_2", "reply": {"type": float, "parser": parser.slicer, "args": [None, -2]}} 63 | # Get bath temperature setpoint 64 | GET_TEMP_SET = {"name": "IN_SP_2", "reply": {"type": float, "parser": parser.slicer, "args": [None, -2]}} 65 | # Set bath temperature 66 | SET_TEMP = {"name": "OUT_SP_2", "type": int, "check": {"min": 0, "max": 180}} 67 | # Get bath safety temperature 68 | GET_TEMP_SAFE = {"name": "IN_SP_3"} 69 | # Set bath safety temperature - doesn't seem to work. 70 | # SET_TEMP_SAFE = {"name":"OUT_SP_3", "type":int, "check":{"min":50, "max":190}} 71 | # Read heating medium type - doesn't seem to work 72 | # GET_BATH_MEDIUM = {"name":"IN_SP_74", "reply":{"type":int}} 73 | # Set heating bath medium type 74 | # SET_BATH_MEDIUM = {"name":"OUT_SP_74", "type":int, "check":{"values":HEATING_MEDIUMS}} 75 | # Start heating 76 | START_HEAT = {"name": "START_2"} 77 | # Stop heating 78 | STOP_HEAT = {"name": "STOP_2"} 79 | 80 | # ################### Configuration commands ############################# 81 | 82 | # Reset rotavap & switch back to local control mode 83 | RESET = {"name": "RESET"} 84 | # Get rotavap status 85 | GET_STATUS = {"name": "STATUS", "reply": {"type": str}} 86 | # Set timer mode duration, minutes 87 | SET_TIMER_DURATION = {"name": "OUT_SP_60", "type": int, "check": {"min": 1, "max": 199}} 88 | # Set interval mode (left-right rotation) cycle time, seconds 89 | SET_INTERVAL_TIME = {"name": "OUT_SP_61", "type": int, "check": {"min": 1, "max": 60}} 90 | 91 | 92 | class RV10Rotovap(AbstractRotavap): 93 | """ 94 | This provides a Python class for the IKA RV10 rotavap based on the 95 | english section of the original operation manuals for the rotavap and the 96 | heating bath, 20000005206 RV10 bd_082018 and 20000017436 HB digital_092017, 97 | respectively. 98 | """ 99 | 100 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int]): 101 | """Default constructor. 102 | """ 103 | 104 | # Load commands from helper class 105 | self.cmd = RV10RotovapCommands 106 | 107 | # Connection settings 108 | connection_parameters: ConnectionParameters = {} 109 | connection_parameters["port"] = port 110 | connection_parameters["address"] = address 111 | connection_parameters["baudrate"] = 9600 112 | connection_parameters["bytesize"] = serial.SEVENBITS 113 | connection_parameters["parity"] = serial.PARITY_EVEN 114 | 115 | super().__init__(device_name, connection_mode, connection_parameters) 116 | 117 | # Protocol settings 118 | self.command_terminator = "\r\n" 119 | self.reply_terminator = "\r\n" 120 | self.args_delimiter = " " 121 | 122 | # The rotavap activates heating/rotation upon just updating the setpoint 123 | # These internal variables are to track the state & make behavior more predictable. 124 | self._rotating = False 125 | self._speed_setpoint: int = 0 126 | self._heating = False 127 | self._temperature_setpoint: float = 0 128 | 129 | def initialize_device(self): 130 | """Performs reset and do a custom initialization sequence. 131 | """ 132 | 133 | self.send(self.cmd.RESET) 134 | # This is legacy initialization from PL1 135 | # According to Sebastian, without it RV didn't enter remote control mode 136 | # TODO Check if it's actually needed 137 | self.start_temperature_regulation() 138 | self.stop_temperature_regulation() 139 | self.start_rotation() 140 | self.stop_rotation() 141 | self.start_task(interval=10, method=self.get_temperature) 142 | 143 | @in_simulation_device_returns(RV10RotovapCommands.DEFAULT_NAME) 144 | def is_connected(self) -> bool: 145 | """Checks if device is connected. 146 | """ 147 | 148 | try: 149 | reply = self.send(self.cmd.GET_NAME) 150 | except PLConnectionError: 151 | return False 152 | return reply == self.cmd.DEFAULT_NAME 153 | 154 | def is_idle(self) -> bool: 155 | """Checks if device is ready - no explicit method for that. 156 | """ 157 | 158 | if not self.is_connected(): 159 | return False 160 | return not (self._heating or self._rotating) 161 | 162 | def get_status(self): 163 | """Not yet implemented. #TODO 164 | """ 165 | raise NotImplementedError 166 | 167 | def check_errors(self): 168 | """Not yet implemented. #TODO 169 | """ 170 | raise NotImplementedError 171 | 172 | def clear_errors(self): 173 | """Not yet implemented. #TODO 174 | """ 175 | raise NotImplementedError 176 | 177 | def start(self): 178 | """Starts evaporation. 179 | """ 180 | 181 | self.start_rotation() 182 | self.lift_down() 183 | self.start_bath() 184 | 185 | def stop(self): 186 | """Stops evaporation. 187 | """ 188 | 189 | self.stop_bath() 190 | self.lift_up() 191 | self.stop_rotation() 192 | 193 | def start_bath(self): 194 | """Starts heating. 195 | """ 196 | 197 | self.send(self.cmd.START_HEAT) 198 | self._heating = True 199 | # This has to be done after the internal variable update 200 | # so that the actual device setting is updated 201 | self.set_temperature(self._temperature_setpoint) 202 | 203 | def stop_bath(self): 204 | """Stops heating. 205 | """ 206 | 207 | self.send(self.cmd.STOP_HEAT) 208 | self._heating = False 209 | 210 | def set_temperature(self, temperature: float, sensor: int = 0): 211 | """Sets the desired bath temperature. 212 | 213 | Args: 214 | temperature (float): Temperature setpoint in °C. 215 | sensor (int): Specify which temperature probe the setpoint applies to. 216 | This device has only an internal sensor. 217 | Thus, the sensor variable has no effect here. 218 | """ 219 | 220 | # If heating is not on, just update internal variable 221 | if not self._heating: 222 | # Check value against limits before updating 223 | self.check_value(self.cmd.SET_TEMP, temperature) 224 | self._temperature_setpoint = temperature 225 | else: 226 | self.send(self.cmd.SET_TEMP, temperature) 227 | self._temperature_setpoint = temperature 228 | 229 | def get_temperature(self, sensor: int = 0) -> float: 230 | """Gets current bath temperature. 231 | 232 | Args: 233 | sensor (int): Specify which temperature probe the setpoint applies to. 234 | This device has only an internal sensor. 235 | Thus, the sensor variable has no effect here. 236 | """ 237 | 238 | return self.send(self.cmd.GET_TEMP) 239 | 240 | def get_temperature_setpoint(self, sensor: int = 0) -> float: 241 | """Reads the current temperature setpoint. 242 | 243 | Args: 244 | sensor (int): Specify which temperature probe the setpoint applies to. 245 | This device has only an internal sensor. 246 | Thus, the sensor variable has no effect here. 247 | """ 248 | 249 | return self._temperature_setpoint 250 | 251 | def start_rotation(self): 252 | """Starts rotation. 253 | """ 254 | 255 | self.send(self.cmd.START_ROTATION) 256 | self._rotating = True 257 | # This has to be done after the internal variable update 258 | # so that the actual device setting is updated 259 | self.set_speed(self._speed_setpoint) 260 | 261 | def stop_rotation(self): 262 | """Stops rotation. 263 | """ 264 | 265 | self.send(self.cmd.STOP_ROTATION) 266 | self._rotating = False 267 | 268 | def set_speed(self, speed: int): 269 | """Sets desired rotation speed. 270 | """ 271 | 272 | # If rotation is not on, just update internal variable 273 | if not self._rotating: 274 | # Check value against limits before updating 275 | self.check_value(self.cmd.SET_SPEED, speed) 276 | self._speed_setpoint = speed 277 | else: 278 | self.send(self.cmd.SET_SPEED, speed) 279 | self._speed_setpoint = speed 280 | 281 | def get_speed(self) -> int: 282 | """Gets actual rotation speed. 283 | """ 284 | 285 | return self.send(self.cmd.GET_SPEED) 286 | 287 | def get_speed_setpoint(self) -> int: 288 | """Gets current rotation speed setpoint. 289 | """ 290 | 291 | return self._speed_setpoint 292 | 293 | def lift_up(self): 294 | """Move evaporation flask up. 295 | """ 296 | 297 | self.send(self.cmd.LIFT_UP) 298 | 299 | def lift_down(self): 300 | """Move evaporation flask down. 301 | """ 302 | 303 | self.send(self.cmd.LIFT_DOWN) 304 | -------------------------------------------------------------------------------- /PyLabware/examples/concurrent_tasks.py: -------------------------------------------------------------------------------- 1 | """This is an example of performing concurrent tasks on IKA RCT digital hotplate. 2 | 3 | Usage: python concurrent_tasks.py 4 | 5 | Three tasks are defined - printing out temperature, printing out stirring speed, 6 | and setting stirring speed to a random value. 7 | First, logging is set up, device object is created and initialized. 8 | Default parameters are set. 9 | Then three task threads are created, started and the main thread sleeps for the 10 | defined amount of time. After that all background tasks are stopped simultaneously 11 | and device is disconnected. 12 | """ 13 | 14 | import logging 15 | import random 16 | import sys 17 | import time 18 | 19 | from PyLabware import RCTDigitalHotplate 20 | 21 | filename = "testing_log_" + time.strftime("%H-%M-%S", time.localtime()) + ".log" 22 | 23 | logging.basicConfig( 24 | format='%(asctime)s.%(msecs)03d %(thread)d %(levelname)-8s %(message)s', 25 | level=logging.INFO, 26 | filename=filename, 27 | datefmt='%H:%M:%S') 28 | 29 | log = logging.getLogger() 30 | 31 | 32 | def print_temperature(): 33 | log.critical("temp:read:%s", ika.get_temperature()) 34 | 35 | 36 | def print_stirring_speed(): 37 | log.critical("speed:read:%s", ika.get_speed()) 38 | 39 | 40 | def random_set_speed(): 41 | speed = random.choice(range(100, 500, 20)) 42 | ika.set_speed(speed) 43 | log.critical("speed:write:%s", speed) 44 | 45 | 46 | if __name__ == "__main__": 47 | 48 | # PyLabware device object 49 | ika = RCTDigitalHotplate(device_name="IKA hotplate", port=4443, connection_mode="tcpip", address="130.209.220.161") 50 | log.error("Device created and connected.") 51 | ika.initialize_device() 52 | 53 | log.error("Init done.") 54 | 55 | # Set temperature 56 | ika.set_temperature(50) 57 | ika.set_speed(50) 58 | ika.start() 59 | 60 | t1 = ika.start_task(interval=1, method=print_temperature, args=None) 61 | log.error("Temperature monitoring started.") 62 | id1 = t1.ident 63 | t2 = ika.start_task(interval=2, method=print_stirring_speed, args=None) 64 | log.error("Speed monitoring started.") 65 | id2 = t2.ident 66 | t3 = ika.start_task(interval=8, method=random_set_speed, args=None) 67 | log.error("Random speed setting started.") 68 | id3 = t3.ident 69 | 70 | # Main thread sleeps 71 | try: 72 | sleep_minutes = int(sys.argv[1]) 73 | except (IndexError, ValueError, TypeError): 74 | sleep_minutes = 1 75 | time.sleep(sleep_minutes * 60) 76 | 77 | ika.stop_all_tasks() 78 | log.error("All background tasks stopped.") 79 | 80 | ika.stop() 81 | ika.disconnect() 82 | log.error("Device disconnected.") 83 | -------------------------------------------------------------------------------- /PyLabware/examples/new_device_template.py: -------------------------------------------------------------------------------- 1 | """PyLabware driver for NEW_DEVICE.""" 2 | 3 | # You may want to import serial if the device is using serial connection and any 4 | # connection options (baudrate/parity/...) need to be changed 5 | # import serial 6 | 7 | # You would need appropriate abstract types from typing 8 | from typing import Optional, Union 9 | 10 | # Core imports 11 | from .. import parsers as parser 12 | from ..controllers import APPROPRIATE_ABSTRACT_DEVICE, in_simulation_device_returns 13 | 14 | # You would typically need at minimum SLConnectionError to handle broken 15 | # connection exceptions properly in is_connected()/is_idle() 16 | # from ..exceptions import SLConnectionError 17 | 18 | from ..models import LabDeviceCommands, ConnectionParameters 19 | 20 | 21 | class NEW_DEVICECommands(LabDeviceCommands): 22 | """Collection of command definitions for for NEW_DEVICE. These commands are 23 | based on the section of the manufacturers user manual, 24 | version , pages . 25 | """ 26 | 27 | # ########################## Constants ################################## 28 | # Add any relevant constants/literals - e.g device id, name - to this 29 | # section. 30 | NEW_DEVICE_NAME = "MY_AWESOME_DEVICE" 31 | 32 | # ################### Control commands ################################### 33 | # Add command dealing with device control/operation to this section. 34 | # An example of command with no reply and arguments 35 | BLINK_SCREEN = {"name": "BS"} 36 | 37 | # An example of command with no reply and a single int argument 38 | SET_TEMP = {"name": "ST", "type": int} 39 | 40 | # An example of command with no reply and a single int argument with value checking 41 | SET_TEMP_WITH_CHECK = {"name": "ST", "type": int, "check": {"min": 20, "max": 300}} 42 | 43 | # An example of command with no reply and a single str argument with value checking 44 | SET_ROTATION_DIR = {"name": "SRD", "type": str, "check": {"values": ["cw", "ccw", "CW", "CCW"]}} 45 | 46 | # An example of command with no arguments and an integer reply that has to 47 | # be cut out from reply string at positions 2-5 48 | GET_TEMP = {"name": "GT", "reply": {"type": str, "parser": parser.slicer, "args": [2, 5]}} 49 | 50 | # ################### Configuration commands ############################# 51 | # Add commands altering device configuration/settings to this section. 52 | 53 | 54 | class NEW_DEVICE(APPROPRIATE_ABSTRACT_DEVICE): 55 | """ 56 | This provides a Python class for the IKA RCT Digital hotplate 57 | based on the english section of the original 58 | operation manual 201811_IKAPlate-Lab_A1_25002139a. 59 | """ 60 | 61 | def __init__(self, device_name: str, connection_mode: str, address: Optional[str], port: Union[str, int], auto_connect: bool): 62 | """Default constructor 63 | """ 64 | 65 | # Load commands from helper class 66 | self.cmd = NEW_DEVICECommands 67 | 68 | # Connection settings 69 | connection_parameters: ConnectionParameters = {} 70 | # Change any connection settings to device specific ones, if needed 71 | # connection_parameters["port"] = port 72 | # connection_parameters["address"] = address 73 | # connection_parameters["baudrate"] = 9600 74 | # connection_parameters["bytesize"] = serial.SEVENBITS 75 | # connection_parameters["parity"] = serial.PARITY_EVEN 76 | 77 | super().__init__(device_name, connection_mode, connection_parameters, auto_connect) 78 | 79 | # Protocol settings 80 | # Terminator for the command string (from host to device) 81 | self.command_terminator = "\r\n" 82 | # Terminator for the reply string (from device to host) 83 | self.reply_terminator = "\r\n" 84 | # Separator between command and command arguments, if any 85 | self.args_delimiter = " " 86 | 87 | def initialise_device(self): 88 | """Set default operation mode & reset. 89 | """ 90 | 91 | # Wrapping is_connected is an easy way to ensure correct behavior in 92 | # simulation. See respective documentation section for the detailed explanation. 93 | @in_simulation_device_returns(NEW_DEVICECommands.NEW_DEVICE_NAME) 94 | def is_connected(self) -> bool: 95 | """""" 96 | 97 | def is_idle(self) -> bool: 98 | """""" 99 | 100 | def get_status(self): 101 | """""" 102 | 103 | def check_errors(self): 104 | """""" 105 | 106 | def clear_errors(self): 107 | """""" 108 | -------------------------------------------------------------------------------- /PyLabware/examples/notebooks/heidolph_hei100.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import ipywidgets\n", 10 | "import logging\n", 11 | "\n", 12 | "from PyLabware import HeiTorque100PrecisionStirrer\n", 13 | "\n", 14 | "fh = logging.FileHandler('hei100.log')\n", 15 | "ch = logging.StreamHandler()\n", 16 | "ch.setLevel(logging.INFO)\n", 17 | "\n", 18 | "logging.basicConfig(\n", 19 | " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", 20 | " level=logging.DEBUG,\n", 21 | " handlers=[fh, ch]\n", 22 | ")\n", 23 | "\n", 24 | "# Silent ipython loggers\n", 25 | "logging.getLogger('parso').setLevel(logging.INFO)\n", 26 | "logging.getLogger('asyncio').setLevel(logging.INFO)\n", 27 | "\n", 28 | "# List available COM ports\n", 29 | "import serial.tools.list_ports\n", 30 | "ports = []\n", 31 | "for p in serial.tools.list_ports.comports():\n", 32 | " ports.append((p.device, p.description))\n", 33 | " \n", 34 | "# Create callbacks\n", 35 | "def on_tab_change(change):\n", 36 | " global conn_mode, port\n", 37 | " if change['new'] == 1:\n", 38 | " conn_mode = 'tcpip'\n", 39 | " port = t_port.value\n", 40 | " else:\n", 41 | " conn_mode = 'serial'\n", 42 | " port = s_port.value\n", 43 | "\n", 44 | "def on_serial_port_change(change):\n", 45 | " global port\n", 46 | " port = s_port.value\n", 47 | "\n", 48 | "def on_tcp_port_change(change):\n", 49 | " global port\n", 50 | " port = t_port.value\n", 51 | " \n", 52 | "def on_button_clicked(change):\n", 53 | " global os\n", 54 | " os = HeiTorque100PrecisionStirrer(\n", 55 | " device_name='overheadStirrer',\n", 56 | " connection_mode=conn_mode,\n", 57 | " address = t_address.value,\n", 58 | " port=port,\n", 59 | " auto_connect=False\n", 60 | " )\n", 61 | "\n", 62 | "# Create widgets\n", 63 | "# Serial stuff\n", 64 | "s_port = ipywidgets.Dropdown(options=[(dev[1], dev[0]) for dev in ports], value=ports[0][0], description='Serial port:')\n", 65 | "s_baudrate = ipywidgets.Dropdown(options=[50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800,\n", 66 | " 9600, 19200, 38400, 57600, 115200], value=9600, description='Baudrate:')\n", 67 | "s_parity = ipywidgets.Dropdown(options=[('None','N'), ('Even', 'E'),('Odd','O'),('Mark','M'),('Space','S')], value='N', description='Parity:')\n", 68 | "s_stopbits = ipywidgets.Dropdown(options=[1, 1.5, 2], value=1, description='Stop bits:')\n", 69 | "s_databits = ipywidgets.Dropdown(options=[5, 6, 7, 8], value=8, description='Data bits:')\n", 70 | "\n", 71 | "# TCP/IP stuff\n", 72 | "t_address = ipywidgets.Text(value='localhost', description='Address:')\n", 73 | "t_port = ipywidgets.Text(value='5000', description='Port:')\n", 74 | "t_protocol = ipywidgets.Dropdown(options=['TCP', 'UDP'], value='TCP', description='Protocol:')\n", 75 | "\n", 76 | "# Create tabs\n", 77 | "serial_main = ipywidgets.HBox(children=[s_port, s_baudrate])\n", 78 | "serial_dataframe = ipywidgets.HBox(children=[s_databits, s_parity, s_stopbits])\n", 79 | "serial_tab = ipywidgets.VBox(children=[serial_main, serial_dataframe])\n", 80 | "tcpip_tab = ipywidgets.HBox(children=[t_protocol, t_address, t_port])\n", 81 | "tabs = ipywidgets.Tab(children=[serial_tab, tcpip_tab])\n", 82 | "tabs.set_title(0, 'Serial connection')\n", 83 | "tabs.set_title(1, 'TCP/IP connection')\n", 84 | "# Bind the callbacks\n", 85 | "tabs.observe(on_tab_change, names='selected_index')\n", 86 | "s_port.observe(on_serial_port_change)\n", 87 | "t_port.observe(on_tcp_port_change)\n", 88 | "# Create button\n", 89 | "create_button = ipywidgets.Button(description='Create', tooltip='Create object with the settings above')\n", 90 | "# Bind the callback\n", 91 | "create_button.on_click(on_button_clicked)\n", 92 | "\n", 93 | "# Show stuff\n", 94 | "display(tabs, create_button)" 95 | ] 96 | }, 97 | { 98 | "cell_type": "code", 99 | "execution_count": null, 100 | "metadata": {}, 101 | "outputs": [], 102 | "source": [ 103 | "os.is_connected()" 104 | ] 105 | }, 106 | { 107 | "cell_type": "code", 108 | "execution_count": null, 109 | "metadata": {}, 110 | "outputs": [], 111 | "source": [ 112 | "os.connect()" 113 | ] 114 | }, 115 | { 116 | "cell_type": "code", 117 | "execution_count": null, 118 | "metadata": {}, 119 | "outputs": [], 120 | "source": [ 121 | "os.is_connected()" 122 | ] 123 | }, 124 | { 125 | "cell_type": "code", 126 | "execution_count": null, 127 | "metadata": {}, 128 | "outputs": [], 129 | "source": [ 130 | "os.initialise_device()" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "metadata": {}, 137 | "outputs": [], 138 | "source": [ 139 | "os.identify()" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": null, 145 | "metadata": {}, 146 | "outputs": [], 147 | "source": [ 148 | "os.get_status()" 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": null, 154 | "metadata": {}, 155 | "outputs": [], 156 | "source": [ 157 | "os.check_error()" 158 | ] 159 | }, 160 | { 161 | "cell_type": "code", 162 | "execution_count": null, 163 | "metadata": {}, 164 | "outputs": [], 165 | "source": [ 166 | "os.clear_error()" 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": null, 172 | "metadata": {}, 173 | "outputs": [], 174 | "source": [ 175 | "os.is_idle()" 176 | ] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "execution_count": null, 181 | "metadata": {}, 182 | "outputs": [], 183 | "source": [ 184 | "os.set_speed(50)" 185 | ] 186 | }, 187 | { 188 | "cell_type": "code", 189 | "execution_count": null, 190 | "metadata": {}, 191 | "outputs": [], 192 | "source": [ 193 | "os.start()" 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": null, 199 | "metadata": {}, 200 | "outputs": [], 201 | "source": [ 202 | "os.get_speed()" 203 | ] 204 | }, 205 | { 206 | "cell_type": "code", 207 | "execution_count": null, 208 | "metadata": {}, 209 | "outputs": [], 210 | "source": [ 211 | "os.get_torque()" 212 | ] 213 | }, 214 | { 215 | "cell_type": "code", 216 | "execution_count": null, 217 | "metadata": { 218 | "scrolled": true 219 | }, 220 | "outputs": [], 221 | "source": [ 222 | "os.calibrate_torque()" 223 | ] 224 | }, 225 | { 226 | "cell_type": "code", 227 | "execution_count": null, 228 | "metadata": {}, 229 | "outputs": [], 230 | "source": [ 231 | "os.get_torque()" 232 | ] 233 | }, 234 | { 235 | "cell_type": "code", 236 | "execution_count": null, 237 | "metadata": {}, 238 | "outputs": [], 239 | "source": [ 240 | "os.stop()" 241 | ] 242 | }, 243 | { 244 | "cell_type": "code", 245 | "execution_count": null, 246 | "metadata": {}, 247 | "outputs": [], 248 | "source": [ 249 | "os.calibrate_torque()" 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": null, 255 | "metadata": {}, 256 | "outputs": [], 257 | "source": [ 258 | "# once again\n", 259 | "os.start()" 260 | ] 261 | }, 262 | { 263 | "cell_type": "code", 264 | "execution_count": null, 265 | "metadata": {}, 266 | "outputs": [], 267 | "source": [ 268 | "os.stop()" 269 | ] 270 | }, 271 | { 272 | "cell_type": "code", 273 | "execution_count": null, 274 | "metadata": {}, 275 | "outputs": [], 276 | "source": [ 277 | "os.disconnect()" 278 | ] 279 | } 280 | ], 281 | "metadata": { 282 | "kernelspec": { 283 | "display_name": "Python 3.8.1 64-bit", 284 | "language": "python", 285 | "name": "python38164bit911eba6cacf04b82a01012cf7772c710" 286 | }, 287 | "language_info": { 288 | "codemirror_mode": { 289 | "name": "ipython", 290 | "version": 3 291 | }, 292 | "file_extension": ".py", 293 | "mimetype": "text/x-python", 294 | "name": "python", 295 | "nbconvert_exporter": "python", 296 | "pygments_lexer": "ipython3", 297 | "version": "3.8.1" 298 | } 299 | }, 300 | "nbformat": 4, 301 | "nbformat_minor": 4 302 | } -------------------------------------------------------------------------------- /PyLabware/examples/notebooks/ika_rct_digital.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import logging\n", 10 | "\n", 11 | "fh = logging.FileHandler('ika_rct_digital.log')\n", 12 | "ch = logging.StreamHandler()\n", 13 | "ch.setLevel(logging.INFO)\n", 14 | "\n", 15 | "logging.basicConfig(\n", 16 | " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", 17 | " level=logging.DEBUG,\n", 18 | " handlers=[fh, ch]\n", 19 | ")\n", 20 | "\n", 21 | "logging.getLogger('parso').setLevel(logging.WARNING)" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": {}, 28 | "outputs": [], 29 | "source": [ 30 | "from PyLabware import RCTDigitalHotplate" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": null, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "hs = RCTDigitalHotplate(\n", 40 | " device_name='hotplateStirrer',\n", 41 | " connection_mode='tcpip',\n", 42 | " address='192.168.1.203',\n", 43 | " port=5000,\n", 44 | " auto_connect=False\n", 45 | ")" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "hs.connect()" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "hs.initialise_device()" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "hs.is_connected()" 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "hs.is_ready()" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "hs.set_temperature(50)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": null, 96 | "metadata": {}, 97 | "outputs": [], 98 | "source": [ 99 | "hs.start_heating()" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "# heating started\n", 109 | "temp = hs.get_temperature()\n", 110 | "print(temp)" 111 | ] 112 | }, 113 | { 114 | "cell_type": "code", 115 | "execution_count": null, 116 | "metadata": {}, 117 | "outputs": [], 118 | "source": [ 119 | "set_temp = hs.get_temperature_setpoint()\n", 120 | "print(set_temp)" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": {}, 127 | "outputs": [], 128 | "source": [ 129 | "ext_temp = hs.get_temperature_external()\n", 130 | "print(ext_temp)" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "metadata": {}, 137 | "outputs": [], 138 | "source": [ 139 | "# all temperatures correct\n", 140 | "hs.stop_heating()" 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "# heating stopped, testing stirring\n", 150 | "hs.set_speed(100)" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": null, 156 | "metadata": {}, 157 | "outputs": [], 158 | "source": [ 159 | "hs.start_stirring()" 160 | ] 161 | }, 162 | { 163 | "cell_type": "code", 164 | "execution_count": null, 165 | "metadata": {}, 166 | "outputs": [], 167 | "source": [ 168 | "# stirring started\n", 169 | "speed = hs.get_speed()\n", 170 | "print(speed)" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": null, 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [ 179 | "set_speed = hs.get_speed_setpoint()\n", 180 | "print(set_speed)" 181 | ] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": null, 186 | "metadata": {}, 187 | "outputs": [], 188 | "source": [ 189 | "hs.set_speed(250)" 190 | ] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "metadata": {}, 196 | "outputs": [], 197 | "source": [ 198 | "# speed change works\n", 199 | "hs.stop_stirring()" 200 | ] 201 | }, 202 | { 203 | "cell_type": "code", 204 | "execution_count": null, 205 | "metadata": {}, 206 | "outputs": [], 207 | "source": [ 208 | "hs.start()" 209 | ] 210 | }, 211 | { 212 | "cell_type": "code", 213 | "execution_count": null, 214 | "metadata": {}, 215 | "outputs": [], 216 | "source": [ 217 | "hs.stop()" 218 | ] 219 | }, 220 | { 221 | "cell_type": "markdown", 222 | "metadata": {}, 223 | "source": [ 224 | "*Start-stop works*" 225 | ] 226 | }, 227 | { 228 | "cell_type": "code", 229 | "execution_count": null, 230 | "metadata": {}, 231 | "outputs": [], 232 | "source": [ 233 | "visc_trend = hs.get_viscosity_trend()\n", 234 | "print(visc_trend)" 235 | ] 236 | }, 237 | { 238 | "cell_type": "code", 239 | "execution_count": null, 240 | "metadata": {}, 241 | "outputs": [], 242 | "source": [ 243 | "hs.get_tasks()" 244 | ] 245 | }, 246 | { 247 | "cell_type": "code", 248 | "execution_count": null, 249 | "metadata": {}, 250 | "outputs": [], 251 | "source": [ 252 | "hs.start_task(5, hs.get_temperature_external)" 253 | ] 254 | }, 255 | { 256 | "cell_type": "code", 257 | "execution_count": null, 258 | "metadata": {}, 259 | "outputs": [], 260 | "source": [ 261 | "hs.start_heating()" 262 | ] 263 | }, 264 | { 265 | "cell_type": "code", 266 | "execution_count": null, 267 | "metadata": {}, 268 | "outputs": [], 269 | "source": [ 270 | "hs.stop_heating()" 271 | ] 272 | }, 273 | { 274 | "cell_type": "code", 275 | "execution_count": null, 276 | "metadata": {}, 277 | "outputs": [], 278 | "source": [ 279 | "hs.get_tasks()" 280 | ] 281 | }, 282 | { 283 | "cell_type": "code", 284 | "execution_count": null, 285 | "metadata": {}, 286 | "outputs": [], 287 | "source": [ 288 | "hs.stop_all_tasks()" 289 | ] 290 | }, 291 | { 292 | "cell_type": "code", 293 | "execution_count": null, 294 | "metadata": {}, 295 | "outputs": [], 296 | "source": [ 297 | "hs.stop_heating()" 298 | ] 299 | }, 300 | { 301 | "cell_type": "code", 302 | "execution_count": null, 303 | "metadata": {}, 304 | "outputs": [], 305 | "source": [ 306 | "hs.disconnect()" 307 | ] 308 | }, 309 | { 310 | "cell_type": "code", 311 | "execution_count": null, 312 | "metadata": {}, 313 | "outputs": [], 314 | "source": [ 315 | "hs.start()" 316 | ] 317 | }, 318 | { 319 | "cell_type": "code", 320 | "execution_count": null, 321 | "metadata": {}, 322 | "outputs": [], 323 | "source": [] 324 | } 325 | ], 326 | "metadata": { 327 | "kernelspec": { 328 | "display_name": "Python 3", 329 | "language": "python", 330 | "name": "python3" 331 | }, 332 | "language_info": { 333 | "codemirror_mode": { 334 | "name": "ipython", 335 | "version": 3 336 | }, 337 | "file_extension": ".py", 338 | "mimetype": "text/x-python", 339 | "name": "python", 340 | "nbconvert_exporter": "python", 341 | "pygments_lexer": "ipython3", 342 | "version": "3.8.1" 343 | } 344 | }, 345 | "nbformat": 4, 346 | "nbformat_minor": 4 347 | } 348 | -------------------------------------------------------------------------------- /PyLabware/examples/notebooks/ika_ret_control_visc.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import logging\n", 10 | "fh = logging.FileHandler('ika_ret_control_visc.log')\n", 11 | "ch = logging.StreamHandler()\n", 12 | "ch.setLevel(logging.INFO)\n", 13 | "\n", 14 | "logging.basicConfig(\n", 15 | " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", 16 | " level=logging.DEBUG,\n", 17 | " handlers=[fh, ch]\n", 18 | ")\n", 19 | "\n", 20 | "logging.getLogger('parso').setLevel(logging.WARNING)\n", 21 | "\n", 22 | "from PyLabware import RETControlViscHotplate" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": 2, 28 | "metadata": {}, 29 | "outputs": [ 30 | { 31 | "name": "stderr", 32 | "output_type": "stream", 33 | "text": [ 34 | "2020-03-21 20:16:06,727 - PyLabware.connections.TCPIPConnection - INFO - Creating connection object with the following settings: \n", 35 | "{'port': '5000', 'address': '192.168.1.100', 'baudrate': 9600, 'bytesize': 7, 'parity': 'E'}\n" 36 | ] 37 | } 38 | ], 39 | "source": [ 40 | "stirrer = RETControlViscHotplate(device_name=\"test\", port=\"5000\", connection_mode=\"tcpip\", address=\"192.168.1.100\", auto_connect=False)" 41 | ] 42 | }, 43 | { 44 | "cell_type": "code", 45 | "execution_count": 3, 46 | "metadata": {}, 47 | "outputs": [ 48 | { 49 | "name": "stdout", 50 | "output_type": "stream", 51 | "text": [ 52 | "\n" 53 | ] 54 | } 55 | ], 56 | "source": [ 57 | "print(stirrer)" 58 | ] 59 | }, 60 | { 61 | "cell_type": "code", 62 | "execution_count": 4, 63 | "metadata": {}, 64 | "outputs": [ 65 | { 66 | "name": "stderr", 67 | "output_type": "stream", 68 | "text": [ 69 | "2020-03-21 20:16:08,014 - PyLabware.connections.TCPIPConnection - INFO - Starting connection listener...\n", 70 | "2020-03-21 20:16:08,016 - PyLabware.connections.TCPIPConnection - INFO - Opened connection to <192.168.1.100:5000>\n", 71 | "2020-03-21 20:16:08,017 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Opened connection.\n" 72 | ] 73 | } 74 | ], 75 | "source": [ 76 | "stirrer.connect()" 77 | ] 78 | }, 79 | { 80 | "cell_type": "code", 81 | "execution_count": 5, 82 | "metadata": {}, 83 | "outputs": [ 84 | { 85 | "name": "stderr", 86 | "output_type": "stream", 87 | "text": [ 88 | "2020-03-21 20:16:09,925 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_NAME\\r\\n'>\n", 89 | "2020-03-21 20:16:10,549 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'IKARET\\r\\n'>\n" 90 | ] 91 | }, 92 | { 93 | "data": { 94 | "text/plain": [ 95 | "True" 96 | ] 97 | }, 98 | "execution_count": 5, 99 | "metadata": {}, 100 | "output_type": "execute_result" 101 | } 102 | ], 103 | "source": [ 104 | "stirrer.is_connected()" 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": 6, 110 | "metadata": {}, 111 | "outputs": [ 112 | { 113 | "name": "stderr", 114 | "output_type": "stream", 115 | "text": [ 116 | "2020-03-21 20:16:12,558 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_VERSION\\r\\n'>\n", 117 | "2020-03-21 20:16:13,183 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'11068\\r\\n'>\n" 118 | ] 119 | }, 120 | { 121 | "data": { 122 | "text/plain": [ 123 | "'11068'" 124 | ] 125 | }, 126 | "execution_count": 6, 127 | "metadata": {}, 128 | "output_type": "execute_result" 129 | } 130 | ], 131 | "source": [ 132 | "stirrer.send(stirrer.cmd.GET_VERSION)" 133 | ] 134 | }, 135 | { 136 | "cell_type": "code", 137 | "execution_count": 7, 138 | "metadata": {}, 139 | "outputs": [ 140 | { 141 | "name": "stderr", 142 | "output_type": "stream", 143 | "text": [ 144 | "2020-03-21 20:16:54,376 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'OUT_SP_4 1000\\r\\n'>\n" 145 | ] 146 | } 147 | ], 148 | "source": [ 149 | "stirrer.set_speed(1000)" 150 | ] 151 | }, 152 | { 153 | "cell_type": "code", 154 | "execution_count": 8, 155 | "metadata": {}, 156 | "outputs": [ 157 | { 158 | "name": "stderr", 159 | "output_type": "stream", 160 | "text": [ 161 | "2020-03-21 20:17:09,268 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_PV_4\\r\\n'>\n", 162 | "2020-03-21 20:17:09,891 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'0.0 4\\r\\n'>\n" 163 | ] 164 | }, 165 | { 166 | "data": { 167 | "text/plain": [ 168 | "0.0" 169 | ] 170 | }, 171 | "execution_count": 8, 172 | "metadata": {}, 173 | "output_type": "execute_result" 174 | } 175 | ], 176 | "source": [ 177 | "stirrer.get_speed()" 178 | ] 179 | }, 180 | { 181 | "cell_type": "code", 182 | "execution_count": 9, 183 | "metadata": {}, 184 | "outputs": [ 185 | { 186 | "name": "stderr", 187 | "output_type": "stream", 188 | "text": [ 189 | "2020-03-21 20:17:34,206 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'START_4\\r\\n'>\n" 190 | ] 191 | } 192 | ], 193 | "source": [ 194 | "stirrer.start_stirring()" 195 | ] 196 | }, 197 | { 198 | "cell_type": "code", 199 | "execution_count": 10, 200 | "metadata": {}, 201 | "outputs": [ 202 | { 203 | "name": "stderr", 204 | "output_type": "stream", 205 | "text": [ 206 | "2020-03-21 20:17:41,439 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_PV_4\\r\\n'>\n", 207 | "2020-03-21 20:17:42,063 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'640.0 4\\r\\n'>\n" 208 | ] 209 | }, 210 | { 211 | "data": { 212 | "text/plain": [ 213 | "640.0" 214 | ] 215 | }, 216 | "execution_count": 10, 217 | "metadata": {}, 218 | "output_type": "execute_result" 219 | } 220 | ], 221 | "source": [ 222 | "stirrer.get_speed()" 223 | ] 224 | }, 225 | { 226 | "cell_type": "code", 227 | "execution_count": 14, 228 | "metadata": {}, 229 | "outputs": [ 230 | { 231 | "name": "stderr", 232 | "output_type": "stream", 233 | "text": [ 234 | "2020-03-21 20:19:15,149 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'STOP_4\\r\\n'>\n" 235 | ] 236 | } 237 | ], 238 | "source": [ 239 | "stirrer.stop_stirring()" 240 | ] 241 | }, 242 | { 243 | "cell_type": "code", 244 | "execution_count": 15, 245 | "metadata": {}, 246 | "outputs": [ 247 | { 248 | "name": "stderr", 249 | "output_type": "stream", 250 | "text": [ 251 | "2020-03-21 20:19:22,859 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_PV_4\\r\\n'>\n", 252 | "2020-03-21 20:19:23,482 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'0.0 4\\r\\n'>\n" 253 | ] 254 | }, 255 | { 256 | "data": { 257 | "text/plain": [ 258 | "0.0" 259 | ] 260 | }, 261 | "execution_count": 15, 262 | "metadata": {}, 263 | "output_type": "execute_result" 264 | } 265 | ], 266 | "source": [ 267 | "stirrer.get_speed()" 268 | ] 269 | }, 270 | { 271 | "cell_type": "code", 272 | "execution_count": 16, 273 | "metadata": {}, 274 | "outputs": [ 275 | { 276 | "name": "stderr", 277 | "output_type": "stream", 278 | "text": [ 279 | "2020-03-21 20:19:33,933 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_PV_2\\r\\n'>\n", 280 | "2020-03-21 20:19:34,556 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'22.6 2\\r\\n'>\n" 281 | ] 282 | }, 283 | { 284 | "data": { 285 | "text/plain": [ 286 | "22.6" 287 | ] 288 | }, 289 | "execution_count": 16, 290 | "metadata": {}, 291 | "output_type": "execute_result" 292 | } 293 | ], 294 | "source": [ 295 | "stirrer.get_temperature()" 296 | ] 297 | }, 298 | { 299 | "cell_type": "code", 300 | "execution_count": 17, 301 | "metadata": {}, 302 | "outputs": [ 303 | { 304 | "name": "stderr", 305 | "output_type": "stream", 306 | "text": [ 307 | "2020-03-21 20:19:46,907 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'OUT_SP_2 30\\r\\n'>\n" 308 | ] 309 | } 310 | ], 311 | "source": [ 312 | "stirrer.set_temperature(30)" 313 | ] 314 | }, 315 | { 316 | "cell_type": "code", 317 | "execution_count": 18, 318 | "metadata": {}, 319 | "outputs": [ 320 | { 321 | "name": "stderr", 322 | "output_type": "stream", 323 | "text": [ 324 | "2020-03-21 20:20:03,708 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'START_1\\r\\n'>\n" 325 | ] 326 | } 327 | ], 328 | "source": [ 329 | "stirrer.start_heating()" 330 | ] 331 | }, 332 | { 333 | "cell_type": "code", 334 | "execution_count": 19, 335 | "metadata": {}, 336 | "outputs": [ 337 | { 338 | "name": "stderr", 339 | "output_type": "stream", 340 | "text": [ 341 | "2020-03-21 20:20:11,196 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_PV_2\\r\\n'>\n", 342 | "2020-03-21 20:20:11,819 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'23.0 2\\r\\n'>\n" 343 | ] 344 | }, 345 | { 346 | "data": { 347 | "text/plain": [ 348 | "23.0" 349 | ] 350 | }, 351 | "execution_count": 19, 352 | "metadata": {}, 353 | "output_type": "execute_result" 354 | } 355 | ], 356 | "source": [ 357 | "stirrer.get_temperature()" 358 | ] 359 | }, 360 | { 361 | "cell_type": "code", 362 | "execution_count": 20, 363 | "metadata": {}, 364 | "outputs": [ 365 | { 366 | "name": "stderr", 367 | "output_type": "stream", 368 | "text": [ 369 | "2020-03-21 20:20:19,676 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'STOP_1\\r\\n'>\n" 370 | ] 371 | } 372 | ], 373 | "source": [ 374 | "stirrer.stop_heating()" 375 | ] 376 | }, 377 | { 378 | "cell_type": "code", 379 | "execution_count": 21, 380 | "metadata": {}, 381 | "outputs": [ 382 | { 383 | "name": "stderr", 384 | "output_type": "stream", 385 | "text": [ 386 | "2020-03-21 20:20:27,924 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Sent message <'IN_PV_2\\r\\n'>\n", 387 | "2020-03-21 20:20:28,546 - PyLabware.controllers.RETControlViscHotplate.test - INFO - Raw reply from the device: <'25.0 2\\r\\n'>\n" 388 | ] 389 | }, 390 | { 391 | "data": { 392 | "text/plain": [ 393 | "25.0" 394 | ] 395 | }, 396 | "execution_count": 21, 397 | "metadata": {}, 398 | "output_type": "execute_result" 399 | } 400 | ], 401 | "source": [ 402 | "stirrer.get_temperature()" 403 | ] 404 | }, 405 | { 406 | "cell_type": "code", 407 | "execution_count": null, 408 | "metadata": {}, 409 | "outputs": [], 410 | "source": [] 411 | } 412 | ], 413 | "metadata": { 414 | "kernelspec": { 415 | "display_name": "Python 3", 416 | "language": "python", 417 | "name": "python3" 418 | }, 419 | "language_info": { 420 | "codemirror_mode": { 421 | "name": "ipython", 422 | "version": 3 423 | }, 424 | "file_extension": ".py", 425 | "mimetype": "text/x-python", 426 | "name": "python", 427 | "nbconvert_exporter": "python", 428 | "pygments_lexer": "ipython3", 429 | "version": "3.7.3" 430 | } 431 | }, 432 | "nbformat": 4, 433 | "nbformat_minor": 4 434 | } 435 | -------------------------------------------------------------------------------- /PyLabware/examples/notebooks/ika_rv_10.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import logging\n", 10 | "\n", 11 | "fh = logging.FileHandler('ika_rv_10.log')\n", 12 | "ch = logging.StreamHandler()\n", 13 | "ch.setLevel(logging.INFO)\n", 14 | "\n", 15 | "logging.basicConfig(\n", 16 | " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", 17 | " level=logging.DEBUG,\n", 18 | " handlers=[fh, ch]\n", 19 | ")\n", 20 | "\n", 21 | "logging.getLogger('parso').setLevel(logging.WARNING)" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": {}, 28 | "outputs": [], 29 | "source": [ 30 | "from PyLabware import RV10Rotovap" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": null, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "rv = RV10Rotovap(\n", 40 | " device_name='rotavap',\n", 41 | " connection_mode='tcpip',\n", 42 | " address='192.168.1.202',\n", 43 | " port=5000,\n", 44 | " auto_connect=False\n", 45 | ")" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "rv.connect()" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "rv.initialise_device()" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "rv.is_connected()" 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "rv.is_ready()" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "rv.set_temperature(40)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": null, 96 | "metadata": {}, 97 | "outputs": [], 98 | "source": [ 99 | "rv.start_heating()" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "temp_setpoint = rv.get_temperature_setpoint()\n", 109 | "print(temp_setpoint)" 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": null, 115 | "metadata": {}, 116 | "outputs": [], 117 | "source": [ 118 | "# actual set point is 55 (from previous command)" 119 | ] 120 | }, 121 | { 122 | "cell_type": "code", 123 | "execution_count": null, 124 | "metadata": {}, 125 | "outputs": [], 126 | "source": [ 127 | "rv.stop_heating()" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": null, 133 | "metadata": {}, 134 | "outputs": [], 135 | "source": [ 136 | "rv.set_temperature(35)" 137 | ] 138 | }, 139 | { 140 | "cell_type": "code", 141 | "execution_count": null, 142 | "metadata": {}, 143 | "outputs": [], 144 | "source": [ 145 | "rv.start_heating()" 146 | ] 147 | }, 148 | { 149 | "cell_type": "code", 150 | "execution_count": null, 151 | "metadata": {}, 152 | "outputs": [], 153 | "source": [ 154 | "rv.set_temperature(50)" 155 | ] 156 | }, 157 | { 158 | "cell_type": "markdown", 159 | "metadata": {}, 160 | "source": [ 161 | "## Error E02 of the heating bath!" 162 | ] 163 | }, 164 | { 165 | "cell_type": "code", 166 | "execution_count": null, 167 | "metadata": {}, 168 | "outputs": [], 169 | "source": [ 170 | "rv.set_speed(150)" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": null, 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [ 179 | "rv.start_rotation()" 180 | ] 181 | }, 182 | { 183 | "cell_type": "code", 184 | "execution_count": null, 185 | "metadata": {}, 186 | "outputs": [], 187 | "source": [ 188 | "speed = rv.get_speed()\n", 189 | "print(speed)" 190 | ] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "metadata": {}, 196 | "outputs": [], 197 | "source": [ 198 | "speed_setpoint = rv.get_speed_setpoint()\n", 199 | "print(speed_setpoint)" 200 | ] 201 | }, 202 | { 203 | "cell_type": "code", 204 | "execution_count": null, 205 | "metadata": {}, 206 | "outputs": [], 207 | "source": [ 208 | "rv.lift_down()\n", 209 | "rv.lift_up()" 210 | ] 211 | }, 212 | { 213 | "cell_type": "code", 214 | "execution_count": null, 215 | "metadata": {}, 216 | "outputs": [], 217 | "source": [ 218 | "rv.stop()" 219 | ] 220 | }, 221 | { 222 | "cell_type": "code", 223 | "execution_count": null, 224 | "metadata": {}, 225 | "outputs": [], 226 | "source": [ 227 | "rv.get_temperature()" 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": null, 233 | "metadata": {}, 234 | "outputs": [], 235 | "source": [ 236 | "rv.start_heating()" 237 | ] 238 | }, 239 | { 240 | "cell_type": "code", 241 | "execution_count": null, 242 | "metadata": {}, 243 | "outputs": [], 244 | "source": [ 245 | "rv.stop()" 246 | ] 247 | }, 248 | { 249 | "cell_type": "code", 250 | "execution_count": null, 251 | "metadata": {}, 252 | "outputs": [], 253 | "source": [ 254 | "rv.get_temperature()" 255 | ] 256 | }, 257 | { 258 | "cell_type": "code", 259 | "execution_count": null, 260 | "metadata": {}, 261 | "outputs": [], 262 | "source": [ 263 | "rv.start_task?" 264 | ] 265 | }, 266 | { 267 | "cell_type": "code", 268 | "execution_count": null, 269 | "metadata": { 270 | "jupyter": { 271 | "outputs_hidden": true 272 | } 273 | }, 274 | "outputs": [], 275 | "source": [ 276 | "rv.start_task(5, rv.get_temperature)" 277 | ] 278 | }, 279 | { 280 | "cell_type": "code", 281 | "execution_count": null, 282 | "metadata": {}, 283 | "outputs": [], 284 | "source": [ 285 | "rv.start_heating()" 286 | ] 287 | }, 288 | { 289 | "cell_type": "code", 290 | "execution_count": null, 291 | "metadata": { 292 | "jupyter": { 293 | "outputs_hidden": true 294 | } 295 | }, 296 | "outputs": [], 297 | "source": [ 298 | "rv.set_temperature(35)" 299 | ] 300 | }, 301 | { 302 | "cell_type": "code", 303 | "execution_count": null, 304 | "metadata": {}, 305 | "outputs": [], 306 | "source": [ 307 | "rv.get_tasks()" 308 | ] 309 | }, 310 | { 311 | "cell_type": "code", 312 | "execution_count": null, 313 | "metadata": {}, 314 | "outputs": [], 315 | "source": [ 316 | "rv.stop_heating()" 317 | ] 318 | }, 319 | { 320 | "cell_type": "code", 321 | "execution_count": null, 322 | "metadata": {}, 323 | "outputs": [], 324 | "source": [ 325 | "rv.stop_all_tasks??" 326 | ] 327 | }, 328 | { 329 | "cell_type": "code", 330 | "execution_count": null, 331 | "metadata": { 332 | "jupyter": { 333 | "outputs_hidden": true 334 | } 335 | }, 336 | "outputs": [], 337 | "source": [ 338 | "rv.stop_task??" 339 | ] 340 | }, 341 | { 342 | "cell_type": "code", 343 | "execution_count": null, 344 | "metadata": {}, 345 | "outputs": [], 346 | "source": [ 347 | "rv.stop_all_tasks()" 348 | ] 349 | } 350 | ], 351 | "metadata": { 352 | "kernelspec": { 353 | "display_name": "Python 3", 354 | "language": "python", 355 | "name": "python3" 356 | }, 357 | "language_info": { 358 | "codemirror_mode": { 359 | "name": "ipython", 360 | "version": 3 361 | }, 362 | "file_extension": ".py", 363 | "mimetype": "text/x-python", 364 | "name": "python", 365 | "nbconvert_exporter": "python", 366 | "pygments_lexer": "ipython3", 367 | "version": "3.8.1" 368 | } 369 | }, 370 | "nbformat": 4, 371 | "nbformat_minor": 4 372 | } 373 | -------------------------------------------------------------------------------- /PyLabware/examples/notebooks/julabo_cf41.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import logging\n", 10 | "\n", 11 | "fh = logging.FileHandler('julabo_cf41.log')\n", 12 | "ch = logging.StreamHandler()\n", 13 | "ch.setLevel(logging.INFO)\n", 14 | "\n", 15 | "logging.basicConfig(\n", 16 | " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", 17 | " level=logging.DEBUG,\n", 18 | " handlers=[fh, ch]\n", 19 | ")\n", 20 | "\n", 21 | "logging.getLogger('parso').setLevel(logging.WARNING)" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": {}, 28 | "outputs": [], 29 | "source": [ 30 | "from PyLabware import CF41Chiller" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": null, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "ch = CF41Chiller(\n", 40 | " device_name='chiller',\n", 41 | " connection_mode='tcpip',\n", 42 | " address='192.168.1.205',\n", 43 | " port=5000,\n", 44 | " auto_connect=False\n", 45 | ")" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "ch.connect()" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "ch.initialise_device()" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "status = ch.get_status()\n", 73 | "print(status)" 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": null, 79 | "metadata": {}, 80 | "outputs": [], 81 | "source": [] 82 | } 83 | ], 84 | "metadata": { 85 | "kernelspec": { 86 | "display_name": "Python 3", 87 | "language": "python", 88 | "name": "python3" 89 | }, 90 | "language_info": { 91 | "codemirror_mode": { 92 | "name": "ipython", 93 | "version": 3 94 | }, 95 | "file_extension": ".py", 96 | "mimetype": "text/x-python", 97 | "name": "python", 98 | "nbconvert_exporter": "python", 99 | "pygments_lexer": "ipython3", 100 | "version": "3.8.1" 101 | } 102 | }, 103 | "nbformat": 4, 104 | "nbformat_minor": 4 105 | } 106 | -------------------------------------------------------------------------------- /PyLabware/examples/notebooks/vacuubrand_cvc3000.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import logging\n", 10 | "\n", 11 | "fh = logging.FileHandler('cvc3000.log')\n", 12 | "ch = logging.StreamHandler()\n", 13 | "ch.setLevel(logging.INFO)\n", 14 | "\n", 15 | "logging.basicConfig(\n", 16 | " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", 17 | " level=logging.DEBUG,\n", 18 | " handlers=[fh, ch]\n", 19 | ")\n", 20 | "\n", 21 | "logging.getLogger('parso').setLevel(logging.WARNING)" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": {}, 28 | "outputs": [], 29 | "source": [ 30 | "from PyLabware import CVC3000VacuumPump" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": null, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "vp = CVC3000VacuumPump(\n", 40 | " device_name='vacuumPump',\n", 41 | " connection_mode='tcpip',\n", 42 | " address='192.168.1.201',\n", 43 | " port=5000,\n", 44 | " auto_connect=False\n", 45 | ")" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "vp.initialise_device()" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "vp.connect()" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "vp.initialise_device()" 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "vp.is_connected()" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "vp.is_running()" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": null, 96 | "metadata": {}, 97 | "outputs": [], 98 | "source": [ 99 | "vp.is_ready()" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "vp.set_remote(True)" 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": null, 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "status = vp.get_status()\n", 118 | "print(status)" 119 | ] 120 | }, 121 | { 122 | "cell_type": "code", 123 | "execution_count": null, 124 | "metadata": {}, 125 | "outputs": [], 126 | "source": [ 127 | "status_verbose = vp.get_status(verbose=True)\n", 128 | "print(status_verbose)" 129 | ] 130 | }, 131 | { 132 | "cell_type": "code", 133 | "execution_count": null, 134 | "metadata": {}, 135 | "outputs": [], 136 | "source": [ 137 | "cfg = vp.get_configuration()\n", 138 | "print(cfg)" 139 | ] 140 | }, 141 | { 142 | "cell_type": "code", 143 | "execution_count": null, 144 | "metadata": {}, 145 | "outputs": [], 146 | "source": [ 147 | "cfg_verbose = vp.get_configuration(verbose=True)\n", 148 | "print(cfg_verbose)" 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": null, 154 | "metadata": {}, 155 | "outputs": [], 156 | "source": [ 157 | "errors = vp.get_errors()\n", 158 | "print(errors)" 159 | ] 160 | }, 161 | { 162 | "cell_type": "code", 163 | "execution_count": null, 164 | "metadata": {}, 165 | "outputs": [], 166 | "source": [ 167 | "errors_verbose = vp.get_errors(numeric=False)\n", 168 | "print(errors_verbose)" 169 | ] 170 | }, 171 | { 172 | "cell_type": "code", 173 | "execution_count": null, 174 | "metadata": {}, 175 | "outputs": [], 176 | "source": [ 177 | "vp.set_mode('0')" 178 | ] 179 | }, 180 | { 181 | "cell_type": "code", 182 | "execution_count": null, 183 | "metadata": {}, 184 | "outputs": [], 185 | "source": [ 186 | "vp.set_mode(0)" 187 | ] 188 | }, 189 | { 190 | "cell_type": "code", 191 | "execution_count": null, 192 | "metadata": {}, 193 | "outputs": [], 194 | "source": [ 195 | "vp.get_mode()" 196 | ] 197 | }, 198 | { 199 | "cell_type": "code", 200 | "execution_count": null, 201 | "metadata": {}, 202 | "outputs": [], 203 | "source": [ 204 | "vp.set_mode(1)" 205 | ] 206 | }, 207 | { 208 | "cell_type": "code", 209 | "execution_count": null, 210 | "metadata": {}, 211 | "outputs": [], 212 | "source": [ 213 | "vp.get_mode()" 214 | ] 215 | }, 216 | { 217 | "cell_type": "code", 218 | "execution_count": null, 219 | "metadata": {}, 220 | "outputs": [], 221 | "source": [ 222 | "for mode in vp.cmd.OPERATION_MODES:\n", 223 | " vp.set_mode(mode)\n", 224 | " m = vp.get_mode()\n", 225 | " print(m)" 226 | ] 227 | }, 228 | { 229 | "cell_type": "code", 230 | "execution_count": null, 231 | "metadata": {}, 232 | "outputs": [], 233 | "source": [ 234 | "vp.cmd.OPERATION_MODES" 235 | ] 236 | }, 237 | { 238 | "cell_type": "code", 239 | "execution_count": null, 240 | "metadata": {}, 241 | "outputs": [], 242 | "source": [ 243 | "vp.set_mode(31)" 244 | ] 245 | }, 246 | { 247 | "cell_type": "code", 248 | "execution_count": null, 249 | "metadata": {}, 250 | "outputs": [], 251 | "source": [ 252 | "vp.set_mode(32)" 253 | ] 254 | }, 255 | { 256 | "cell_type": "code", 257 | "execution_count": null, 258 | "metadata": {}, 259 | "outputs": [], 260 | "source": [ 261 | "vp.set_mode(3)" 262 | ] 263 | }, 264 | { 265 | "cell_type": "code", 266 | "execution_count": null, 267 | "metadata": {}, 268 | "outputs": [], 269 | "source": [ 270 | "vp.set_mode(1)" 271 | ] 272 | }, 273 | { 274 | "cell_type": "code", 275 | "execution_count": null, 276 | "metadata": {}, 277 | "outputs": [], 278 | "source": [ 279 | "vp.set_mode(2)" 280 | ] 281 | }, 282 | { 283 | "cell_type": "code", 284 | "execution_count": null, 285 | "metadata": {}, 286 | "outputs": [], 287 | "source": [ 288 | "vp.set_mode(4)" 289 | ] 290 | }, 291 | { 292 | "cell_type": "code", 293 | "execution_count": null, 294 | "metadata": {}, 295 | "outputs": [], 296 | "source": [ 297 | "vp.set_mode(2)" 298 | ] 299 | }, 300 | { 301 | "cell_type": "code", 302 | "execution_count": null, 303 | "metadata": {}, 304 | "outputs": [], 305 | "source": [ 306 | "vp.set_pressure(400)" 307 | ] 308 | }, 309 | { 310 | "cell_type": "code", 311 | "execution_count": null, 312 | "metadata": {}, 313 | "outputs": [], 314 | "source": [ 315 | "pressure = vp.get_pressure_setpoint()\n", 316 | "print(pressure)" 317 | ] 318 | }, 319 | { 320 | "cell_type": "code", 321 | "execution_count": null, 322 | "metadata": {}, 323 | "outputs": [], 324 | "source": [ 325 | "vp.start()" 326 | ] 327 | }, 328 | { 329 | "cell_type": "code", 330 | "execution_count": null, 331 | "metadata": {}, 332 | "outputs": [], 333 | "source": [ 334 | "cur_pressure = vp.get_pressure()\n", 335 | "print(cur_pressure)" 336 | ] 337 | }, 338 | { 339 | "cell_type": "code", 340 | "execution_count": null, 341 | "metadata": {}, 342 | "outputs": [], 343 | "source": [ 344 | "speed = vp.get_pump_speed()\n", 345 | "print(speed)" 346 | ] 347 | }, 348 | { 349 | "cell_type": "code", 350 | "execution_count": null, 351 | "metadata": {}, 352 | "outputs": [], 353 | "source": [ 354 | "set_speed = vp.get_pump_speed_setpoint()\n", 355 | "print(set_speed)" 356 | ] 357 | }, 358 | { 359 | "cell_type": "code", 360 | "execution_count": null, 361 | "metadata": {}, 362 | "outputs": [], 363 | "source": [ 364 | "vp.set_pressure(1000)" 365 | ] 366 | }, 367 | { 368 | "cell_type": "code", 369 | "execution_count": null, 370 | "metadata": {}, 371 | "outputs": [], 372 | "source": [ 373 | "end_pressure_setpoint = vp.get_end_pressure_setpoint()\n", 374 | "print(end_pressure_setpoint)" 375 | ] 376 | }, 377 | { 378 | "cell_type": "code", 379 | "execution_count": null, 380 | "metadata": {}, 381 | "outputs": [], 382 | "source": [ 383 | "vp.set_end_pressure(550)" 384 | ] 385 | }, 386 | { 387 | "cell_type": "code", 388 | "execution_count": null, 389 | "metadata": {}, 390 | "outputs": [], 391 | "source": [ 392 | "end_pressure_setpoint = vp.get_end_pressure_setpoint()\n", 393 | "print(end_pressure_setpoint)" 394 | ] 395 | }, 396 | { 397 | "cell_type": "code", 398 | "execution_count": null, 399 | "metadata": {}, 400 | "outputs": [], 401 | "source": [ 402 | "vp.is_vent_open()" 403 | ] 404 | }, 405 | { 406 | "cell_type": "code", 407 | "execution_count": null, 408 | "metadata": {}, 409 | "outputs": [], 410 | "source": [ 411 | "# vacuum sealed, testing vent\n", 412 | "vp.set_pressure(400)" 413 | ] 414 | }, 415 | { 416 | "cell_type": "code", 417 | "execution_count": null, 418 | "metadata": {}, 419 | "outputs": [], 420 | "source": [ 421 | "vp.get_pressure()" 422 | ] 423 | }, 424 | { 425 | "cell_type": "code", 426 | "execution_count": null, 427 | "metadata": {}, 428 | "outputs": [], 429 | "source": [ 430 | "vp.set_pressure(800)" 431 | ] 432 | }, 433 | { 434 | "cell_type": "code", 435 | "execution_count": null, 436 | "metadata": {}, 437 | "outputs": [], 438 | "source": [ 439 | "vp.get_pressure()" 440 | ] 441 | }, 442 | { 443 | "cell_type": "code", 444 | "execution_count": null, 445 | "metadata": {}, 446 | "outputs": [], 447 | "source": [ 448 | "vp.get_status(True)" 449 | ] 450 | }, 451 | { 452 | "cell_type": "code", 453 | "execution_count": null, 454 | "metadata": {}, 455 | "outputs": [], 456 | "source": [ 457 | "vp.set_pressure(400)" 458 | ] 459 | }, 460 | { 461 | "cell_type": "code", 462 | "execution_count": null, 463 | "metadata": {}, 464 | "outputs": [], 465 | "source": [ 466 | "vp.stop()" 467 | ] 468 | }, 469 | { 470 | "cell_type": "code", 471 | "execution_count": null, 472 | "metadata": {}, 473 | "outputs": [], 474 | "source": [ 475 | "vp.vent_on()" 476 | ] 477 | }, 478 | { 479 | "cell_type": "code", 480 | "execution_count": null, 481 | "metadata": {}, 482 | "outputs": [], 483 | "source": [ 484 | "vp.start()" 485 | ] 486 | }, 487 | { 488 | "cell_type": "code", 489 | "execution_count": null, 490 | "metadata": {}, 491 | "outputs": [], 492 | "source": [ 493 | "vp.stop()" 494 | ] 495 | }, 496 | { 497 | "cell_type": "code", 498 | "execution_count": null, 499 | "metadata": {}, 500 | "outputs": [], 501 | "source": [ 502 | "vp.vent_on()\n", 503 | "vp.vent_off()" 504 | ] 505 | }, 506 | { 507 | "cell_type": "code", 508 | "execution_count": null, 509 | "metadata": {}, 510 | "outputs": [], 511 | "source": [ 512 | "vp.vent_auto()" 513 | ] 514 | }, 515 | { 516 | "cell_type": "code", 517 | "execution_count": null, 518 | "metadata": {}, 519 | "outputs": [], 520 | "source": [ 521 | "vp.set_end_timeout(1)" 522 | ] 523 | }, 524 | { 525 | "cell_type": "code", 526 | "execution_count": null, 527 | "metadata": {}, 528 | "outputs": [], 529 | "source": [ 530 | "end_timeout = vp.get_end_timeout()\n", 531 | "print(end_timeout)" 532 | ] 533 | }, 534 | { 535 | "cell_type": "code", 536 | "execution_count": null, 537 | "metadata": {}, 538 | "outputs": [], 539 | "source": [ 540 | "vp.set_end_timeout(60)" 541 | ] 542 | }, 543 | { 544 | "cell_type": "code", 545 | "execution_count": null, 546 | "metadata": {}, 547 | "outputs": [], 548 | "source": [ 549 | "end_timeout = vp.get_end_timeout()\n", 550 | "print(end_timeout)" 551 | ] 552 | }, 553 | { 554 | "cell_type": "code", 555 | "execution_count": null, 556 | "metadata": {}, 557 | "outputs": [], 558 | "source": [ 559 | "vp.start()" 560 | ] 561 | }, 562 | { 563 | "cell_type": "code", 564 | "execution_count": null, 565 | "metadata": {}, 566 | "outputs": [], 567 | "source": [ 568 | "# reached timeout in vac_control mode\n", 569 | "status = vp.get_status(True)\n", 570 | "print(status)" 571 | ] 572 | }, 573 | { 574 | "cell_type": "code", 575 | "execution_count": null, 576 | "metadata": {}, 577 | "outputs": [], 578 | "source": [ 579 | "vp.stop()" 580 | ] 581 | }, 582 | { 583 | "cell_type": "code", 584 | "execution_count": null, 585 | "metadata": {}, 586 | "outputs": [], 587 | "source": [ 588 | "vp.start()" 589 | ] 590 | }, 591 | { 592 | "cell_type": "code", 593 | "execution_count": null, 594 | "metadata": {}, 595 | "outputs": [], 596 | "source": [ 597 | "vp.start()" 598 | ] 599 | }, 600 | { 601 | "cell_type": "code", 602 | "execution_count": null, 603 | "metadata": {}, 604 | "outputs": [], 605 | "source": [ 606 | "vp.stop()" 607 | ] 608 | }, 609 | { 610 | "cell_type": "code", 611 | "execution_count": null, 612 | "metadata": {}, 613 | "outputs": [], 614 | "source": [ 615 | "vp.disconnect()" 616 | ] 617 | }, 618 | { 619 | "cell_type": "code", 620 | "execution_count": null, 621 | "metadata": {}, 622 | "outputs": [], 623 | "source": [ 624 | "vp.connect()" 625 | ] 626 | }, 627 | { 628 | "cell_type": "code", 629 | "execution_count": null, 630 | "metadata": {}, 631 | "outputs": [], 632 | "source": [ 633 | "vp.set_end_timeout(3600*3)" 634 | ] 635 | }, 636 | { 637 | "cell_type": "code", 638 | "execution_count": null, 639 | "metadata": {}, 640 | "outputs": [], 641 | "source": [ 642 | "vp.disconnect()" 643 | ] 644 | }, 645 | { 646 | "cell_type": "code", 647 | "execution_count": null, 648 | "metadata": {}, 649 | "outputs": [], 650 | "source": [ 651 | "vp.start()" 652 | ] 653 | }, 654 | { 655 | "cell_type": "code", 656 | "execution_count": null, 657 | "metadata": {}, 658 | "outputs": [], 659 | "source": [ 660 | "vp.connect()" 661 | ] 662 | }, 663 | { 664 | "cell_type": "code", 665 | "execution_count": null, 666 | "metadata": {}, 667 | "outputs": [], 668 | "source": [ 669 | "vp.start()" 670 | ] 671 | }, 672 | { 673 | "cell_type": "code", 674 | "execution_count": null, 675 | "metadata": {}, 676 | "outputs": [], 677 | "source": [ 678 | "vp.is_running()" 679 | ] 680 | }, 681 | { 682 | "cell_type": "code", 683 | "execution_count": null, 684 | "metadata": {}, 685 | "outputs": [], 686 | "source": [ 687 | "vp.stop()" 688 | ] 689 | }, 690 | { 691 | "cell_type": "code", 692 | "execution_count": null, 693 | "metadata": {}, 694 | "outputs": [], 695 | "source": [ 696 | "vp.disconnect()" 697 | ] 698 | }, 699 | { 700 | "cell_type": "code", 701 | "execution_count": null, 702 | "metadata": {}, 703 | "outputs": [], 704 | "source": [] 705 | } 706 | ], 707 | "metadata": { 708 | "kernelspec": { 709 | "display_name": "Python 3", 710 | "language": "python", 711 | "name": "python3" 712 | }, 713 | "language_info": { 714 | "codemirror_mode": { 715 | "name": "ipython", 716 | "version": 3 717 | }, 718 | "file_extension": ".py", 719 | "mimetype": "text/x-python", 720 | "name": "python", 721 | "nbconvert_exporter": "python", 722 | "pygments_lexer": "ipython3", 723 | "version": "3.8.1" 724 | } 725 | }, 726 | "nbformat": 4, 727 | "nbformat_minor": 4 728 | } 729 | -------------------------------------------------------------------------------- /PyLabware/exceptions.py: -------------------------------------------------------------------------------- 1 | """PyLabware exceptions""" 2 | 3 | 4 | class PLConnectionError(ConnectionError): 5 | """ Generic connection error, base class.""" 6 | 7 | 8 | class PLConnectionProtocolError(PLConnectionError): 9 | """Error in transport protocol.""" 10 | 11 | 12 | class PLConnectionTimeoutError(PLConnectionError): 13 | """Connection timeout error.""" 14 | 15 | 16 | class PLDeviceError(Exception): 17 | """Generic device error, base class.""" 18 | 19 | 20 | class PLDeviceCommandError(PLDeviceError): 21 | """Error in processing device command. 22 | 23 | This should be any error arising BEFORE the command has been sent to a device. 24 | """ 25 | 26 | 27 | class PLDeviceReplyError(PLDeviceError): 28 | """Error in processing device reply. 29 | 30 | This should be any error arising AFTER the command has been sent to the device. 31 | """ 32 | 33 | 34 | class PLDeviceInternalError(PLDeviceReplyError): 35 | """Error returned by device as a response to command. 36 | """ 37 | -------------------------------------------------------------------------------- /PyLabware/models.py: -------------------------------------------------------------------------------- 1 | """PyLabware data models.""" 2 | 3 | from typing import Dict 4 | from abc import ABC, abstractmethod 5 | 6 | 7 | ConnectionParameters = Dict 8 | """ Leave that till the good times come 9 | class ConnectionParameters(TypedDict, total=False): 10 | address: Optional[str] 11 | api_url: str 12 | baudrate: int 13 | bytesize: int 14 | command_delay: float 15 | dsrdtr: bool 16 | encoding: str 17 | headers: str 18 | inter_byte_timeout: float 19 | parity: str 20 | password: str 21 | port: Union[int, str] 22 | protocol: str 23 | receiving_interval: float 24 | receive_timeout: float 25 | receive_buffer_size: int 26 | rtscts: bool 27 | schema: str 28 | stopbits: float 29 | timeout: float 30 | transmit_timeout: float 31 | user: str 32 | verify_ssl: bool 33 | xonxoff: bool 34 | write_timeout: float 35 | """ 36 | 37 | 38 | class LabDeviceCommands(ABC): 39 | """ This class acts as a container for all device command string 40 | and provides the basic features for value checking and reply parsing. 41 | If more advanced device-specific processing is needed 42 | it has to be done in device driver classes by custom parsing functions. 43 | """ 44 | 45 | def __new__(cls, *args, **kwargs): 46 | """This class shouldn't be instantiated""" 47 | raise NotImplementedError 48 | 49 | 50 | class LabDeviceReply: 51 | """ This class defines the data model for a device reply for all transport types (plain text, HTTP REST, ...) 52 | """ 53 | 54 | __slots__ = ["body", "content_type", "parameters"] 55 | 56 | def __init__(self, body="", content_type="text", parameters=None): 57 | 58 | self.body = body 59 | self.content_type = content_type 60 | self.parameters = parameters 61 | 62 | # ###################################### Base abstract classes ################################################## 63 | 64 | 65 | class AbstractLabDevice(ABC): 66 | """Base abstract class for all labware devices. 67 | """ 68 | 69 | @property 70 | @abstractmethod 71 | def simulation(self): 72 | """ Determines whether the device behaves as as a real or simulated one. 73 | Simulated device just logs all the commands. 74 | """ 75 | 76 | @simulation.setter 77 | def simulation(self, sim): 78 | """ Setter for the simulation property 79 | """ 80 | 81 | @abstractmethod 82 | def connect(self): 83 | """ Connects to the device. 84 | """ 85 | 86 | @abstractmethod 87 | def disconnect(self): 88 | """ Disconnects from the device. 89 | """ 90 | 91 | @abstractmethod 92 | def is_connected(self): 93 | """Checks if connection to the device is active. 94 | This method should issue a device-specific command 95 | (e.g. status/info command) and check the reply from the device 96 | to figure out whether the device is actually responsive, 97 | and not just returns the state of the connection itself 98 | (e.g. underlying connection object is_connection_open() method). 99 | 100 | This method has to catch all potential exceptions 101 | and always return either True or False. 102 | 103 | Returns: 104 | (bool): Whether device is connected or not. 105 | """ 106 | 107 | @abstractmethod 108 | def is_idle(self): 109 | """Checks whether the device is in idle state. 110 | The idle state is defined as following: 111 | 112 | * The device has just been powered on AND is_connected() is True 113 | * OR if device has an idle status indication - if device.status == idle 114 | * OR if device doesn't have an idle status indication - after device.stop() was called 115 | 116 | This method has to execute device-specific command, if possible, 117 | to check whether a device is in idle state as defined above. 118 | 119 | If there's no command to get device status, an internal flag self._running 120 | has to be used. 121 | If the device has multiple activities (e.g. hotplate), an appropriate 122 | set of flags has to be used (idle == not (self._stirring or self._heating)) 123 | 124 | This method has to catch all potential exceptions 125 | and always return either True or False. 126 | 127 | This method has to be redefined in child classes. 128 | 129 | Returns: 130 | (bool): Device ready status 131 | """ 132 | 133 | @abstractmethod 134 | def initialize_device(self): 135 | """Many devices require hardware initialization before they can be used. 136 | This method should run hardware initialization/resetting or setting 137 | all the necessary parameters. 138 | 139 | This method has to be redefined in child classes. 140 | """ 141 | 142 | @abstractmethod 143 | def get_status(self): 144 | """Gets device internal status, if implemented in the device. 145 | """ 146 | 147 | @abstractmethod 148 | def check_errors(self): 149 | """Gets errors from the device (if the device supports it) and raises 150 | SLDeviceInternalError with error-specific message if any errors are 151 | present. 152 | """ 153 | 154 | @abstractmethod 155 | def clear_errors(self): 156 | """Clears internal device errors, if any. 157 | """ 158 | 159 | @abstractmethod 160 | def start(self): 161 | """Main method that starts device's intended activity. 162 | E.g if it's a stirrer, starts stirring. If it's a stirring hotplate - 163 | starts both stirring and heating. For granular control child classes for 164 | the devices capable of multiple activities (e.g. stirring hotplate) 165 | must implement separate methods defined in the respective derivate 166 | abstract classes. 167 | """ 168 | 169 | @abstractmethod 170 | def stop(self): 171 | """ Stops all device activities and brings it back to idle state. 172 | According to the definition of is_idle() above, is_idle() ran after 173 | stop() must return True. 174 | """ 175 | 176 | @abstractmethod 177 | def execute_when_ready(self, action, *args, check_ready): 178 | """Acquires device lock, waits till device is ready 179 | and runs device method. 180 | 181 | Args: 182 | action: A function to run when the device is ready 183 | args: List of arguments for the method to run 184 | check_ready: A method to use for checking whether 185 | the device is ready or not. 186 | 187 | """ 188 | 189 | @abstractmethod 190 | def wait_until_ready(self, check_ready): 191 | """Acquires device lock, waits till device is ready 192 | and returns. 193 | 194 | Args: 195 | check_ready: A method to use for checking whether 196 | the device is ready or not. 197 | 198 | """ 199 | -------------------------------------------------------------------------------- /PyLabware/parsers.py: -------------------------------------------------------------------------------- 1 | """PyLabware utility functions for reply parsing""" 2 | 3 | import re 4 | 5 | 6 | def slicer(reply: str, *args) -> str: 7 | """This is a wrapper function for reply parsing to provide consistent 8 | arguments order. 9 | 10 | Args: 11 | reply: Sequence object to slice. 12 | 13 | Returns: 14 | (any): Slice of the original object. 15 | """ 16 | 17 | return reply[slice(*args)] 18 | 19 | 20 | def researcher(reply, *args): 21 | """This is a wrapper function for reply parsing to provide consistent 22 | arguments order. 23 | 24 | Args: 25 | reply: Reply to parse with regular expression. 26 | 27 | Returns: 28 | (re.Match): Regular expression match object. 29 | """ 30 | 31 | return re.search(*args, reply) 32 | 33 | 34 | def stripper(reply: str, prefix=None, suffix=None) -> str: 35 | """This is a helper function used to strip off reply prefix and 36 | terminator. Standard Python str.strip() doesn't work reliably because 37 | it operates on character-by-character basis, while prefix/terminator 38 | is usually a group of characters. 39 | 40 | Args: 41 | reply: String to be stripped. 42 | prefix: Substring to remove from the beginning of the line. 43 | suffix: Substring to remove from the end of the line. 44 | 45 | Returns: 46 | (str): Naked reply. 47 | """ 48 | 49 | if prefix is not None and reply.startswith(prefix): 50 | reply = reply[len(prefix):] 51 | 52 | if suffix is not None and reply.endswith(suffix): 53 | reply = reply[:-len(suffix)] 54 | 55 | return reply 56 | -------------------------------------------------------------------------------- /PyLabware/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PyLabware](docs/images/_static/logo_with_text_600px.png) 2 | ============ 3 | 4 | This is a simple Python 3 library to control the common range of the hardware found in the chemistry labs - hotplates, stirrers, rotary evaporators, vacuum pumps etc. using a common interface. 5 | 6 | --- 7 | 8 | ## Features 9 | - Wide range of supported hardware 10 | - Supports both serial/RS-232 and Ethernet connection to the devices. 11 | - Provides thread-safe parallel execution of several commands per device. 12 | - Provides single interface per device type irrespective of a particular manufacturer. 13 | - Simulation mode to test your code before executing it with real hardware. 14 | - Easy addition of new devices. 15 | 16 | --- 17 | 18 | ## Setup 19 | Clone this repo to your PC and run `pip install .` from the repository folder. 20 | 21 | --- 22 | 23 | ## Usage 24 | 25 | ``` 26 | >>> import PyLabware as pl 27 | >>> pump = pl.C3000SyringePump(device_name="reagent_pump", port="COM7", 28 | connection_mode="serial", address=None, switch_address=4) 29 | >>> pump.connect() 30 | >>> pump.initialize_device() 31 | >>> pump.get_valve_position() 32 | 'I' 33 | >>> pump.withdraw(200) 34 | >>> pump.set_valve_position("O") 35 | >>> pump.dispense(200) 36 | ... 37 | ``` 38 | --- 39 | 40 | ## License 41 | This project is licensed under [MIT license](LICENSE). 42 | © 2021 [Cronin group](http://croninlab.com) -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXBUILDOPTS ?= "-a" 7 | SPHINXBUILD ?= sphinx-build 8 | SPHINX_APIDOC ?= sphinx-apidoc 9 | SOURCEDIR = ./src 10 | BUILDDIR = ./html 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | @$(SPHINXBUILD) --help 15 | 16 | .PHONY: help Makefile 17 | 18 | clean: 19 | rm -rf $(BUILDDIR) 20 | 21 | cleanall: 22 | @read -p "!!! That would remove all files under .\src !!! Are you sure? " -r; \ 23 | if [[ $$REPLY =~ ^[Yy] ]]; then \ 24 | rm -rf $(BUILDDIR); \ 25 | rm -rf $(SOURCEDIR); \ 26 | fi 27 | 28 | apidoc: 29 | @$(SPHINX_APIDOC) --separate --no-toc --output $(SOURCEDIR) ..\PyLabware 30 | 31 | 32 | # Catch-all target: route all unknown targets to Sphinx using the new 33 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 34 | %:clean 35 | @$(SPHINXBUILD) -M $@ . "$(BUILDDIR)" $(SPHINXBUILDOPTS) $(O) 36 | -------------------------------------------------------------------------------- /docs/README_DOCS.md: -------------------------------------------------------------------------------- 1 | # Build instructions for the documentation 2 | 3 | ## General points 4 | 5 | The documentation is built automatically using Sphinx. 6 | The following Python packages are needed for a successful build: 7 | 8 | * Sphinx 9 | * sphinx-autodoc-typehints 10 | * sphinx-rtd-theme 11 | 12 | Besides the above, you would also need GNU make (as well as coreutils) to run 13 | the build in a convenient way. 14 | 15 | ## Building 16 | 17 | To build the documentation run: 18 | 19 | `make html` 20 | 21 | The resulting pages would be generated under _html/_ subfolder 22 | 23 | To remove the old html files run: 24 | 25 | `make clean` 26 | 27 | (This is performed automatically before each build) 28 | 29 | To generated new templates for API docs run: 30 | 31 | `make apidocs` 32 | 33 | The resulting RST files would be created under _PyLabware_ subdirectory. 34 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath("../")) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'PyLabware' 21 | copyright = 'Cronin Group, 2021' 22 | author = 'Sergey Zalesskiy' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.viewcode', 33 | 'sphinx.ext.napoleon', 34 | 'sphinx_autodoc_typehints', 35 | 'sphinx.ext.todo' 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The language for content autogenerated by Sphinx. Refer to documentation 42 | # for a list of supported languages. 43 | # 44 | # This is also used if you do content translation via gettext catalogs. 45 | # Usually you set "language" from the command line for these cases. 46 | language = 'en' 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 52 | 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | 56 | # The theme to use for HTML and HTML Help pages. See the documentation for 57 | # a list of builtin themes. 58 | # 59 | html_theme = 'sphinx_rtd_theme' 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | #html_static_path = ['_static'] 65 | 66 | # Logo options 67 | html_logo = 'images/_static/logo_white_200px.png' 68 | 69 | # -- Options for autodoc extension ------------------------------------------- 70 | # Include both class and __init__() docstring into class description 71 | autoclass_content = "both" 72 | 73 | # Show type hints in description of function/method 74 | autodoc_typehints = "description" 75 | 76 | # -- Options for todo extension ------------------------------------------- 77 | 78 | # If this is True, todo and todolist produce output, else they produce nothing. The default is False. 79 | todo_include_todos = False 80 | 81 | # If this is True, todo emits a warning for each TODO entries. The default is False. 82 | todo_emit_warnings = True 83 | 84 | # -- Options for autodoc-typehints extension -------------------------------- 85 | 86 | # If True, set typing.TYPE_CHECKING to True to enable "expensive" typing imports. 87 | # The default is False. 88 | set_type_checking_flag = False 89 | # If True, class names are always fully qualified (e.g. module.for.Class). 90 | # If False, just the class name displays (e.g. Class). The default is False. 91 | typehints_fully_qualified = False 92 | # If False, do not add type info for undocumented parameters. If True, add stub 93 | # documentation for undocumented parameters to be able to add type info. 94 | # The default is False. 95 | always_document_param_types = False 96 | # If False, never add an :rtype: directive. If True, add the :rtype: directive 97 | # if no existing :rtype: is found. The default is True. 98 | typehints_document_rtype = False 99 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributing is welcome both in the form of adding new devices support and 5 | enhancing the functionality of the existing ones. 6 | 7 | To report a bug or ask for a feature/enhancement please create an issue 8 | on `Github `_. 9 | 10 | .. todo:: Create issue templates 11 | 12 | Reporting bugs 13 | -------------- 14 | 15 | As most of the bugs that can be encountered are related to the particular 16 | hardware behavior, it would be impossible to reproduce and fix them without 17 | knowing the exact hardware been used. **The following information must be 18 | included with any bug description to be considered:** 19 | 20 | * Full log output at the DEBUG log level (preferably attached as a separate 21 | .log file). 22 | * The Python script that caused the error along with full stack trace. 23 | * Exact brand, model and firmware version of the unit that has been used. 24 | 25 | Please adhere to the following structure when creating an issue: 26 | 27 | * Quick summary. 28 | * Code excerpt/stack trace. 29 | * Comments/suggestions, if any. 30 | 31 | Feature requests 32 | ---------------- 33 | 34 | Please adhere to the following structure when creating an issue: 35 | 36 | * Quick summary. 37 | * Current behavior - describe what's happening/missing now. 38 | * Desired behavior - describe what you would like to have instead and **why**. 39 | 40 | .. _add_new_device: 41 | 42 | Adding new devices 43 | ------------------ 44 | 45 | Here is the rough sequence to follow if you want to add a new device 'XXX': 46 | 47 | 0. Prepare your development tools: 48 | 49 | * Python (see :doc:`overview` for Python version and required packages) 50 | * Flake8 51 | * MyPy 52 | 1. If you have write access to the repository, create a new branch off 53 | ``develop`` named ``add_XXX_support``. If you don't have a write access, fork 54 | the repository and create a branch in your private fork. 55 | 2. Copy :file:`PyLabware/examples/new_Device_template.py` from 56 | :file:`examples` to :file:`devices`. 57 | 3. Rename the file to be _.py 58 | 4. Check the style guide below and write up your code. 59 | 5. When putting the command definitions into the helper class, please, put 60 | **all** commands listed in the device manual even if you are planning to use 61 | only a subset of them. You don't have to implement a method for each command, 62 | but having all of them listed is a burden of the first module author to help 63 | others in future. 64 | 6. Add the user manual into the :file:`manuals` folder and add a 65 | reference to it in the docstring of the command class. 66 | 7. Test your code on the hardware and fix all bugs found. If there are bugs in 67 | the hardware discovered, do describe them in the docstring of the appropriate 68 | method. 69 | 8. Run linting and MyPy check from the repository root:: 70 | 71 | flake8 . 72 | mypy . 73 | 74 | There must be no flake8 errors, and as few MyPy errors as possible. 75 | 9. Before submitting PR ensure to merge in latest changes from the ``develop`` 76 | branch. 77 | 10. Submit a PR to the mainline, provide brief description of the device and 78 | its particular quirks, if any. 79 | 80 | 81 | 82 | Code style 83 | ---------- 84 | 85 | Please, take your time to read `PEP8 `_ 86 | carefully before writing any code. If anything remains unclear, please, take 87 | your time to read it again. We are not strictly following all the PEP8 88 | guidelines, however, ugly code would not be merged. Here is the rough list of 89 | things to check for: 90 | 91 | * **All classes/methods/functions must have meaningful docstrings** 92 | - they are used for automatic generation of documentation. 93 | * `Google style guide `_ 94 | must be followed for the docstrings syntax. 95 | * Apart from the docstrings, **the code must have inline comments.** 96 | * PascalCase is used for class names, underscore case for variable/method names. 97 | All command names use capitalized underscore syntax. 98 | * Lazy formatting is using for logging. 99 | * Log outputs should be preceded by the method name that emits the message. 100 | * All parameters in log messages should be enclosed in <>. 101 | * All code should pass flake8 linting without errors. A configuration file used 102 | in the CI can be found in the repository root. 103 | * Type annotations should be used, but type checking with MyPy is not enforced. 104 | 105 | -------------------------------------------------------------------------------- /docs/data_model.rst: -------------------------------------------------------------------------------- 1 | Data model 2 | ========== 3 | 4 | Data flow 5 | --------- 6 | The flow chart below shows the data flow from the host to the device using the 7 | :py:meth:`set_temperature()` method for the 8 | :doc:`IKA RCT Digital hotplate`: 9 | 10 | .. image:: images/_static/flow_diagram_data_to_device.png 11 | 12 | :py:meth:`~PyLabware.controllers.LabDevice._recv()` is usually called 13 | automatically based on whether reply is expected from device according to the 14 | command definition (see below). However, :py:meth:`_recv()` can be called 15 | explicitly to acquire the data from underlying connection object. The data flow 16 | inside :py:meth:`_recv()` is presented in the next flow chart: 17 | 18 | .. image:: images/_static/flow_diagram_data_from_device.png 19 | 20 | .. _timeouts: 21 | 22 | Timeouts 23 | -------- 24 | 25 | There are several timeouts implemented to facilitate flow control and avoid 26 | deadlocking on the connection level: 27 | 28 | `command_delay` 29 | This is the delay between two sequential commands sent to ensure the device 30 | has enough time to process the first one before getting another one. This 31 | delay is maintained inside the repspective connection class 32 | :py:meth:`transmit()` method. 33 | `receive_timeout` 34 | This is the delay for the underlying connection's :py:meth:`receive()` method. 35 | `transmit_timeout` 36 | This is the delay for the underlying connection's :py:meth:`send()` method. 37 | `receiving_interval` 38 | This is the delay for the connection listener loop to sleep between reading 39 | from the underlying connection object. 40 | 41 | The default values for the timeouts can be found in the 42 | :py:attr:`~PyLabware.connections.AbstractConnection.DEFAULT_CONNECTION_PARAMETERS` 43 | dictionary of the :py:class:`AbstractConnection` class. They can be accessed and 44 | overriden through the :py:attr:`LabDevice.connection` object:: 45 | 46 | >>> LabDevice.connection.command_delay = 2 # Sets inter-command delay to 2 seconds 47 | 48 | 49 | Device command structure 50 | ------------------------ 51 | 52 | Command set for every device is grouped into a helper class acting purely as a 53 | command container. Each command definition is a simple Python dictionary. 54 | The command dictionary contains all the necessary information related to any 55 | particular command including: 56 | 57 | * Argument value type. 58 | * Allowed argument values range/set. 59 | * Information whether a reply is expected after issuing the command. 60 | * Rules for reply parsing. 61 | 62 | Below is an example of command definitions with flattened dictionary structure 63 | for visual compactness:: 64 | 65 | class RCTDigitalHotplateCommands(LabDeviceCommands): 66 | """Collection of command definitions for for IKA RCT Digital stirring hotplate. 67 | """ 68 | ... 69 | # Get internal hotplate sensor temperature 70 | GET_TEMP = {"name": "IN_PV_2", "reply": {"type": float, "parser": parser.slicer, "args": [-2]}} 71 | # Set temperature 72 | SET_TEMP = {"name": "OUT_SP_1", "type": int, "check": {"min": 20, "max": 310}} 73 | ... 74 | 75 | The principal structure of the dictionary is as follows:: 76 | 77 | cmd_name = {"name": "cmd_string", 78 | "type": None, 79 | "check": {}, 80 | "reply": {} 81 | } 82 | 83 | cmd_name 84 | A human-understandable command name used in function 85 | calls. Must be capitalized. Mandatory. 86 | cmd_string 87 | An actual command sent to the device, according to device 88 | specs. Unused fields should be either assigned to `None` or skipped. Mandatory. 89 | type 90 | One of the standard Python types that the command parameter has to be casted to 91 | before sending. Optional. 92 | check 93 | A sub-dictionary defining command parameter checking rules (see below). 94 | Optional. 95 | reply 96 | A sub-dictionary defining command reply handling (see below). Optional. 97 | 98 | .. warning:: PyLabware check the presence of :py:attr:`reply` key in the command 99 | dictionary to judge whether a reply should be expected from the device 100 | after sending a command. 101 | 102 | As there is no universal way to map the reply to the preceding 103 | command, an unsolicited reply would be processed by PyLabware and 104 | given out next time :py:meth:`_recv()` is called, which would break 105 | the data flow. 106 | 107 | Thus, even if no reply is desired, but the device still sends it 108 | back, an empty ``reply: {}`` key should be included in the command 109 | definition. 110 | 111 | The shortest command specification without any parameter value checking and 112 | no reply expected would be:: 113 | 114 | TEST_COMMAND = {"name":"T"} 115 | 116 | The shortest command definition with reply expected would be:: 117 | 118 | TEST_COMMAND = {"name":"T", "reply":{}} 119 | 120 | 121 | Value checking 122 | ************** 123 | 124 | There are two basic options for value checking: built in - min/max check and 125 | value in range check. Custom workflows for value checking are not supported. 126 | 127 | Min/max check 128 | ^^^^^^^^^^^^^ 129 | 130 | Example:: 131 | 132 | SET_TEMP = {"name":"ST", 133 | "type":int, 134 | "check": {"min": 20, 135 | "max": 180 136 | } 137 | } 138 | 139 | min 140 | Minimum allowed value for the command parameter. 141 | max 142 | Maximum allowed value for the command parameter. 143 | 144 | 145 | The example above defines a command with an ``integer`` argument having minimum 146 | allowed value of ``20`` and maximum allowed value of ``180``. Upon invoking 147 | :py:attr:`device.send(SET_TEMP, 52.5)`, the following would happen: 148 | 149 | 1. The value ``52.2`` would be cast to ``SET_TEMP["type"]``, being transformed to 150 | 52. 151 | 2. A check ``is 52 > 20`` would be performed, if not, 152 | :py:meth:`SLDeviceCommandError` exception would be raised. 153 | 3. A check ``is 52 < 180`` would be performed, if not, 154 | :py:exc:`SLDeviceCommandError` exception would be raised. 155 | 4. The checked parameter would be glued together with the ``SET_TEMP["name"]`` along 156 | with the proper termination. 157 | 5. The resulting message (e.g. ``ST 52\r\n``) would be sent to the device after 158 | which :py:meth:`device.send()` would return as no reply is expected according to 159 | the command definition. 160 | 161 | Value in range check 162 | ^^^^^^^^^^^^^^^^^^^^ 163 | 164 | Example:: 165 | 166 | SET_ROTATION_DIR = {"name":"SRD", 167 | "type":str, 168 | "check": {"values": ["CW", "CCW", "cw", "ccw"] 169 | } 170 | } 171 | 172 | values 173 | Any iterable holding discrete values that the command parameter can take. 174 | 175 | The execution flow for this command would be similar to the one above with the 176 | min/max check being replaces by the ``parameter in values`` check. 177 | 178 | Built-in reply parsers 179 | ********************** 180 | 181 | As it was explained above, PyLabware checks for the presence of the :py:attr:`reply` key in 182 | the command definition to figure out whether it should wait for the device reply 183 | after sending a command. The contents of the :py:attr:`reply` define the further 184 | processing of the obtained reply:: 185 | 186 | GET_TEMP = {"name": "IN_PV_2", 187 | "reply": {"type": float, 188 | "parser": parser.slicer, 189 | "args": [-2] 190 | } 191 | } 192 | 193 | type 194 | One of the standard Python types that the reply would be cast to. Optional. 195 | 196 | .. note:: To provide compatibility with more complex processing, type casting 197 | only takes place if the original reply type is one of the basic Python 198 | types - `int`, `float`, `str` or `bool`. 199 | 200 | parser 201 | Any callable to pass the reply to for the further parsing. See the section 202 | on custom parsers below for the detailed description. 203 | 204 | args 205 | A list of positional arguments to pass to the parser. 206 | 207 | A few simple parsers that are used most often are provided in the 208 | :py:mod:`PyLabware.parsers` module. 209 | 210 | Making custom parsers 211 | ********************* 212 | 213 | To make a custom parser when developing your own device driver, 214 | simply define a function taking :py:attr:`reply` as a first argument:: 215 | 216 | def my_parser(reply, dofourtytwo=0): 217 | if dofourtytwo == 42: 218 | return 42 219 | else: 220 | return reply 221 | 222 | Then provide it as a parser in the command definition:: 223 | 224 | MY_AWESOME_CMD = {cmd_name="DO", type: str, "reply":{"parser": my_parser, "args":[42]}} 225 | 226 | Note the following: 227 | 228 | * The absence of quotes around ``my_parser`` in the command definition - 229 | it has to be passed by reference, not by calling it. 230 | * Even when we pass only a single argument, it still has to be passed as a list. 231 | 232 | .. TODO:: Fix the latter - convert a single value to list intrinsically 233 | 234 | Device reply structure 235 | ---------------------- 236 | 237 | .. autoclass:: PyLabware.models.LabDeviceReply 238 | :members: 239 | 240 | content_type 241 | Content type is a literal defining how the reply body should be treated. 242 | Currently, only two types are supported - *text* for plain-text replies and 243 | *json* for JSON replies. 244 | parameters 245 | Optional Python dictionary holding any required reply parameters. 246 | body 247 | The reply body. 248 | 249 | 250 | Abstract classes hierarchy 251 | -------------------------- 252 | 253 | The abstract classes hierarchy is one of the core concepts in PyLabware. It ensures, 254 | that all the devices are classified into a limited number of basic types, 255 | according to their capabilities. Every device type provides a uniform set of 256 | methods irrespective of the particular device's internal implementation. 257 | 258 | This is maintained by a set of hierarchical abstract classes branching down from 259 | Python's standard :py:class:`ABC`. Each abstract class presents a limited set of 260 | basic abstract methods that **must** be implemented by all children along with 261 | any additional methods to provide extra functionality. 262 | 263 | .. autoclass:: PyLabware.models.AbstractLabDevice 264 | :members: 265 | :undoc-members: 266 | :show-inheritance: 267 | 268 | .. autoclass:: PyLabware.controllers.AbstractTemperatureController 269 | :members: 270 | :undoc-members: 271 | :show-inheritance: 272 | 273 | .. autoclass:: PyLabware.controllers.AbstractPressureController 274 | :members: 275 | :undoc-members: 276 | :show-inheritance: 277 | 278 | .. autoclass:: PyLabware.controllers.AbstractStirringController 279 | :members: 280 | :undoc-members: 281 | :show-inheritance: 282 | 283 | .. autoclass:: PyLabware.controllers.AbstractDispensingController 284 | :members: 285 | :undoc-members: 286 | :show-inheritance: 287 | 288 | .. autoclass:: PyLabware.controllers.AbstractHotplate 289 | :members: 290 | :undoc-members: 291 | :show-inheritance: 292 | 293 | .. autoclass:: PyLabware.controllers.AbstractSyringePump 294 | :members: 295 | :undoc-members: 296 | :show-inheritance: 297 | 298 | .. autoclass:: PyLabware.controllers.AbstractDistributionValve 299 | :members: 300 | :undoc-members: 301 | :show-inheritance: 302 | 303 | .. autoclass:: PyLabware.controllers.AbstractRotavap 304 | :members: 305 | :undoc-members: 306 | :show-inheritance: 307 | 308 | .. autoclass:: PyLabware.controllers.AbstractFlashChromatographySystem 309 | :members: 310 | :undoc-members: 311 | :show-inheritance: -------------------------------------------------------------------------------- /docs/images/_static/flow_diagram_data_from_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/_static/flow_diagram_data_from_device.png -------------------------------------------------------------------------------- /docs/images/_static/flow_diagram_data_to_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/_static/flow_diagram_data_to_device.png -------------------------------------------------------------------------------- /docs/images/_static/logo_200px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/_static/logo_200px.png -------------------------------------------------------------------------------- /docs/images/_static/logo_600px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/_static/logo_600px.png -------------------------------------------------------------------------------- /docs/images/_static/logo_white_200px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/_static/logo_white_200px.png -------------------------------------------------------------------------------- /docs/images/_static/logo_with_text_600px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/_static/logo_with_text_600px.png -------------------------------------------------------------------------------- /docs/images/flow_diagram.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/flow_diagram.ai -------------------------------------------------------------------------------- /docs/images/logo/logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/croningp/pylabware/fbf287c7866ad3ac1e1a3bc44845035b8ae4a7b9/docs/images/logo/logo.ai -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyLabware documentation 2 | =========================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | :caption: Contents: 7 | 8 | overview 9 | usage 10 | data_model 11 | src/main 12 | utils 13 | contributing 14 | 15 | .. todolist:: 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | .. image:: images/_static/logo_with_text_600px.png 5 | :alt: 6 | 7 | PyLabware is a simple Python library providing a common interface and 8 | sets of commands to control various pieces of equipment which are typically 9 | found in the chemical laboratory from a Python script. 10 | 11 | There are a number of different manufacturers of lab equipment with some often 12 | being superior compared to others in particular applications and vice versa. 13 | Thus, a typical laboratory often has a bunch of different hotplate brands and 14 | models, a few rotavapors from different manufacturers and so on. 15 | 16 | While not being a problem at all for manual operation, this becomes a major 17 | challenge for any automation as different devices even from a single manufacturer 18 | often expose incompatible communication protocols making it necessary to invest 19 | time and resources into making specific pieces of software to make all the devices 20 | talk to each other. 21 | 22 | Over the recent years Python had made it's way into chemistry labs as a powerful 23 | alternative to LabVIEW and other proprietary tools for various automation jobs. 24 | **PyLabware** aims to take the niche of providing simple and efficient way to 25 | remotely control a bunch of laboratory equipment from higher level automation 26 | scripts. 27 | 28 | PyLabware was originally inspired by the 29 | `SerialLabware `_ 30 | developed by Sebastian Steiner and Stefan Glatzel during their time in the 31 | Cronin Group. [1]_ However, the original code used in the paper was merely a 32 | proof of concept and contained a number of design flaws making it's further 33 | development impractical. 34 | PyLabware, while inspired by the same idea, has been fully written from scratch 35 | in May 2019. This version is much more stable, offers an extended range of 36 | supported devices as well as many new features. 37 | 38 | 39 | Features 40 | -------- 41 | 42 | * Provides a consistent set of commands for every device type. I.e. every 43 | hotplate is obliged to have methods :py:meth:`set_temperature` and 44 | :py:meth:`get_temperature` irrespective of the exact manufacturer's protocol / 45 | command names for that device. 46 | 47 | * Provides an abstract device type hierarchy. 48 | 49 | * Supports multiple transports to physically communicate with device: 50 | 51 | * Standard serial connection with `PySerial `_. 52 | * Standard TCP/UDP over IP connection with Python :py:mod:`sockets`. 53 | * Connection to devices exposing HTTP REST API with Python `Requests `_. 54 | 55 | * Provides an easy way to add new devices with Python dictionary-based command set definitions (see :ref:`add_new_device`). 56 | 57 | * Can run arbitrary tasks for multiple devices in parallel (see :ref:`tasks`). 58 | 59 | * Provides full simulation mode to test any action sequences without physical 60 | devices present (see :ref:`simulation`). 61 | 62 | 63 | Minimal requirements 64 | --------------------- 65 | 66 | * Python 3.8 or newer. 67 | * PySerial package for serial communications. 68 | * Requests package for HTP REST API devices. 69 | * PyYAML package for using OpenAPI parser tool (see :doc:`utils`). 70 | 71 | Platforms 72 | --------- 73 | 74 | The library has been developed and tested on Windows only. In theory, it should 75 | work on \*nix as well, as there is no-platform-specific code, however, the 76 | functionality and performance have not been extensively tested. 77 | 78 | 79 | Documentation 80 | ------------- 81 | 82 | The current documentation with examples can be found `here `_. 83 | 84 | 85 | .. rubric:: Footnotes 86 | .. [1] More details can be found in the `respective paper `_. 87 | -------------------------------------------------------------------------------- /docs/src/connections.rst: -------------------------------------------------------------------------------- 1 | Connections 2 | ================================ 3 | 4 | .. automodule:: PyLabware.connections 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/controllers.rst: -------------------------------------------------------------------------------- 1 | Controllers 2 | ================================ 3 | 4 | .. autofunction:: PyLabware.controllers.in_simulation_device_returns 5 | 6 | .. autoclass:: PyLabware.controllers.LabDevice 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | 11 | .. autoclass:: PyLabware.controllers.LabDeviceTask 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | -------------------------------------------------------------------------------- /docs/src/devices.buchi_c815.rst: -------------------------------------------------------------------------------- 1 | Buchi C815 2 | ========== 3 | 4 | .. automodule:: PyLabware.devices.buchi_c815 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.buchi_r300.rst: -------------------------------------------------------------------------------- 1 | Buchi R300 2 | ========== 3 | 4 | .. automodule:: PyLabware.devices.buchi_r300 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.heidolph_hei_torque_100_precision.rst: -------------------------------------------------------------------------------- 1 | Heidolph HeiTorque 100 Precision 2 | ================================ 3 | 4 | .. automodule:: PyLabware.devices.heidolph_hei_torque_100_precision 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.heidolph_rzr_2052_control.rst: -------------------------------------------------------------------------------- 1 | Heidolph RZR 2052 Control 2 | ========================= 3 | 4 | .. automodule:: PyLabware.devices.heidolph_rzr_2052_control 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.huber_petite_fleur.rst: -------------------------------------------------------------------------------- 1 | Huber Petite Fleur 2 | ================== 3 | 4 | .. automodule:: PyLabware.devices.huber_petite_fleur 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.idex_mxii.rst: -------------------------------------------------------------------------------- 1 | IDEX mxII 2 | ========= 3 | 4 | .. automodule:: PyLabware.devices.idex_mxii 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.ika_microstar_75.rst: -------------------------------------------------------------------------------- 1 | IKA Microstar 75 2 | ================ 3 | 4 | .. automodule:: PyLabware.devices.ika_microstar_75 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.ika_rct_digital.rst: -------------------------------------------------------------------------------- 1 | IKA RCT Digital 2 | =============== 3 | 4 | .. automodule:: PyLabware.devices.ika_rct_digital 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.ika_ret_control_visc.rst: -------------------------------------------------------------------------------- 1 | IKA RET Control Visc 2 | ==================== 3 | 4 | .. automodule:: PyLabware.devices.ika_ret_control_visc 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.ika_rv10.rst: -------------------------------------------------------------------------------- 1 | IKA RV10 2 | ======== 3 | 4 | .. automodule:: PyLabware.devices.ika_rv10 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.julabo_cf41.rst: -------------------------------------------------------------------------------- 1 | Julabo CF41 2 | =========== 3 | 4 | .. automodule:: PyLabware.devices.julabo_cf41 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 4 3 | 4 | devices.buchi_c815 5 | devices.buchi_r300 6 | devices.heidolph_hei_torque_100_precision 7 | devices.heidolph_rzr_2052_control 8 | devices.huber_petite_fleur 9 | devices.ika_microstar_75 10 | devices.ika_rct_digital 11 | devices.ika_ret_control_visc 12 | devices.ika_rv10 13 | devices.idex_mxii.rst 14 | devices.julabo_cf41 15 | devices.tricontinent_c3000 16 | devices.vacuubrand_cvc_3000 17 | 18 | -------------------------------------------------------------------------------- /docs/src/devices.tricontinent_c3000.rst: -------------------------------------------------------------------------------- 1 | Tricontinent C3000 2 | ================== 3 | 4 | .. automodule:: PyLabware.devices.tricontinent_c3000 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/devices.vacuubrand_cvc_3000.rst: -------------------------------------------------------------------------------- 1 | Vacuubrand CVC3000 2 | ================== 3 | 4 | .. automodule:: PyLabware.devices.vacuubrand_cvc_3000 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. automodule:: PyLabware.exceptions 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/src/main.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ===================== 3 | .. toctree:: 4 | :maxdepth: 4 5 | 6 | connections 7 | controllers 8 | exceptions 9 | parsers 10 | 11 | Devices 12 | ----------- 13 | .. toctree:: 14 | devices -------------------------------------------------------------------------------- /docs/src/parsers.rst: -------------------------------------------------------------------------------- 1 | Parsers 2 | ======= 3 | 4 | .. automodule:: PyLabware.parsers 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Importing the library automatically imports all the device modules into the 5 | library namespace to make their usage straightforward: 6 | 7 | >>> import PyLabware as pl 8 | >>> dir(pl) 9 | ['C3000SyringePump', 'C815FlashChromatographySystem', 'CF41Chiller', 10 | 'CVC3000VacuumPump', 'HeiTorque100PrecisionStirrer', 'Microstar75Stirrer', 11 | 'PetiteFleurChiller', 'R300Rotovap', 'RCTDigitalHotplate', 12 | 'RETControlViscHotplate', 'RV10Rotovap', 'RZR2052ControlStirrer', 13 | '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', 14 | '__package__', '__path__', '__spec__', 'connections', 'controllers', 'devices', 15 | 'exceptions', 'models', 'parsers'] 16 | 17 | Basic examples 18 | -------------- 19 | 20 | Creating a device 21 | ***************** 22 | 23 | **Serial connection:** 24 | 25 | >>> import PyLabware as pl 26 | >>> pump = pl.C3000SyringePump(device_name="reagent_pump", port="COM7", 27 | connection_mode="serial", address=None, switch_address=4) 28 | 29 | * :py:attr:`device_name` is an arbitrary string used to identify a device. 30 | * :py:attr:`port` is a serial port name (platform-dependent). 31 | * :py:attr:`connection_mode` determines which :doc:`connection adapter ` would be activated for the device. 32 | * :py:attr:`address` determines IP address/DNS name for socket-based or HTTP REST connection, it is not used for serial connection. 33 | 34 | The rest of constructor parameters are device specific and are described in the corresponding :doc:`module documentation `. 35 | 36 | 37 | **Socket-based connection:** 38 | 39 | >>> import PyLabware as pl 40 | >>> chiller = pl.CF41Chiller(device_name="jacket_chiller", port="5050", 41 | connection_mode="tcpip", address="192.168.0.1") 42 | 43 | **HTTP connection:** 44 | 45 | >>> import PyLabware as pl 46 | >>> rv = pl.R300Rotovap(device_name="rotavap", connection_mode="http", 47 | address="r300.local", password="yourpass", port="4443") 48 | 49 | Operating a device 50 | ****************** 51 | 52 | A general sequence for an arbitrary device would be: 53 | 54 | 1. Create device object. :: 55 | 56 | >>> import PyLabware as pl 57 | >>> pump = pl.C3000SyringePump(device_name="reagent_pump", port="COM7", 58 | connection_mode="serial", address=None, switch_address=4) 59 | 60 | 2. Alter any settings if needed. 61 | 62 | 3. Open connection. :: 63 | 64 | >>> pump.connect() # Open serial connection 65 | 66 | 4. Check that device is alive. :: 67 | 68 | >>> pump.is_connected() # Check that the pump is live 69 | True 70 | 71 | 5. Check that device is initialized. :py:meth:`initialize_device()` handles any 72 | device-specific functionality needed for it to operate properly. :: 73 | 74 | >>> pump.is_initialized() # Check that the pump has been initialized 75 | False 76 | >>> pump.initialize_device() 77 | 78 | 6. Run whatever operations are needed. :: 79 | 80 | >>> pump.get_valve_position() # Check pump distribution valve position 81 | 'I' 82 | >>> pump.withdraw(200) # Withdraw 200 units 83 | >>> pump.get_plunger_position() 84 | 200 85 | >>> pump.set_valve_position("O") # Switch valve 86 | >>> pump.get_valve_position() 87 | 'O' 88 | >>> pump.dispense(200) # Dispense 200 units to another output 89 | >>> pump.get_plunger_position() 90 | 0 91 | 92 | 7. Close the connection and exit. :: 93 | 94 | >>> pump.disconnect() # Close connection before exiting 95 | 96 | .. warning:: Gracefully closing connection before exiting is a good practice. 97 | Otherwise you are relying on a Python garbage collector for 98 | closing the connection when it destroys the device object. 99 | There are no warranties the latter would happen cleanly, 100 | so the physical device might get stuck with the half-open connection. 101 | 102 | Running sequential commands 103 | *************************** 104 | 105 | Often it is important to ensure that the device is idle before sending a 106 | command. A classical example would be a syringe pump running 107 | at low speed that would block any further commands until the current 108 | dispensing/withdrawing is complete. 109 | 110 | .. note:: For the definition of what 'device idle state' means in the context of 111 | this library, please check the documentation for the 112 | :py:meth:`~PyLabware.models.AbstractLabDevice.is_idle()`: 113 | 114 | To indicate whether a device is ready to receive further commands, every device 115 | driver implements a hardware-specific 116 | :py:meth:`~PyLabware.models.AbstractLabDevice.is_idle()` method:: 117 | 118 | >>> import PyLabware as pl 119 | >>> pump = pl.C3000SyringePump(device_name="reagent_pump", port="COM7", 120 | connection_mode="serial", address=None, switch_address=4) 121 | >>> pump.connect() 122 | >>> pump.initialise_device() 123 | >>> pump.is_idle() 124 | True 125 | ########################### 126 | # Set slow withdrawal speed 127 | ########################### 128 | >>> pump.set_speed(20) 129 | >>> pump.withdraw(200) 130 | ############################################################### 131 | # From here the pump would give a hardware error if any further 132 | # plunger movement commands are issued before it has finished move. 133 | ############################################################### 134 | 135 | To make the life easier, a 136 | :py:meth:`~PyLabware.controllers.LabDevice.execute_when_ready()` method 137 | is provided. For most of the device drivers it is internally used when 138 | necessary, so that the end user has nothing to worry about:: 139 | 140 | class C3000SyringePump(AbstractSyringePump, AbstractDistributionValve): 141 | ... 142 | def move_plunger_absolute(self, position: int, set_busy: bool = True): 143 | """Makes absolute plunger move. 144 | """ 145 | 146 | if set_busy is True: 147 | cmd = self.cmd.SYR_MOVE_ABS 148 | else: 149 | cmd = self.cmd.SYR_MOVE_ABS_NOBUSY 150 | # Send command & check reply for errors 151 | self.execute_when_ready(self.send, cmd, position) 152 | 153 | For the detailed syntax please check the corresponding 154 | :doc:`documentation `. 155 | 156 | The :py:meth:`~PyLabware.controllers.LabDevice.wait_until_ready()` 157 | is a simplified wrapper over 158 | :py:meth:`~PyLabware.controllers.LabDevice.execute_when_ready()` 159 | that just blocks until the device is idle. 160 | 161 | .. note:: If using :py:meth:`is_idle()`/:py:meth:`execute_when_ready()` is not 162 | convenient (e.g. the device doesn't report busy/idle state), there is 163 | a simple control flow mechanism built in that ensures there is a 164 | minimal delay between any two successive commands. Read more :ref:`here `. 165 | 166 | 167 | Operating multiple devices 168 | ************************** 169 | 170 | Operating multiple devices is similar to the single device example given above. 171 | The :py:attr:`device_name` attribute can be used to distinguish between device 172 | replies in the log files. 173 | 174 | .. note:: Every device has its own connection object, so concurrent 175 | access to a single serial port from multiple devices is not supported. 176 | 177 | 178 | Advanced examples 179 | ----------------- 180 | 181 | .. _tasks: 182 | 183 | Running concurrent tasks for devices 184 | ************************************ 185 | 186 | PyLabware supports concurrent execution of commands if the device hardware itself 187 | supports it:: 188 | 189 | >>> import PyLabware as pl 190 | >>> pump = pl.C3000SyringePump(device_name="reagent_pump", port="COM7", 191 | connection_mode="serial", address=None, switch_address=4) 192 | >>> pump.connect() 193 | >>> pump.initialise_device() 194 | >>> pump.is_idle() 195 | True 196 | >>> pump.set_speed(20) 197 | >>> pump.withdraw(200) 198 | >>> def print_plunger_position(): 199 | ...: print(f"Plunger position: {pump.get_plunger_position()}") 200 | >>> pump.start_task(interval=5, method=print_plunger_position, args=None) 201 | Plunger position: 0 202 | Plunger position: 0 203 | Plunger position: 0 204 | >>> pump.withdraw(200) 205 | Plunger position: 12 206 | Plunger position: 62 207 | Plunger position: 113 208 | Plunger position: 163 209 | Plunger position: 200 210 | Plunger position: 200 211 | >>> pump.get_all_tasks() 212 | [] 213 | >>> task = pump.get_all_tasks()[0] 214 | >>> pump.stop_task(task) 215 | 216 | In the example above the plunger position is constantly monitored and printed 217 | out while the pump is withdrawing the liquid. Any sensible number of tasks can 218 | be run in parallel and started/stopped independently. A common use case for this 219 | feature would be to issue keep-alive commands so that the device stays active. 220 | 221 | More examples can be found in 222 | :file:`PyLabware/examples/concurrent_tasks.py` 223 | 224 | .. todo:: Add example from IKA RV10 keepalive. 225 | 226 | .. _simulation: 227 | 228 | Simulation mode 229 | *************** 230 | 231 | Often it is desirable to make a dry run of a script before running it on actual 232 | hardware to avoid unnecessary time/material cost and/or to ease up debug and 233 | development. To fulfill this task, every device can be run in simulation 234 | mode. 235 | 236 | The simulation mode is switched on by setting the :py:attr:`simulation` property 237 | to ``True``. Simulation messages are printed to log at INFO level so to use it 238 | you need to configure logging first:: 239 | 240 | >>> import PyLabware as pl 241 | >>> import logging 242 | >>> logging.getLogger().setLevel(logging.INFO) 243 | >>> pump = pl.C3000SyringePump(device_name="reagent_pump", port="COM7", connection_mode="serial", address=None, switch_address=4) 244 | [INFO] :: PyLabware.connections.SerialConnection :: Creating connection object with the following settings: 245 | {'address': None, 'port': 'COM7', 'encoding': 'UTF-8', 'command_delay': 0.5, 246 | 'receive_buffer_size': 128, 'receive_timeout': 1, 'transmit_timeout': 1, 247 | 'receiving_interval': 0.05, 'write_timeout': 0.5, 'baudrate': 9600, 248 | 'bytesize': 8, 'parity': 'N', 'stopbits': 1, 'xonxoff': False, 'rtscts': False, 249 | 'dsrdtr': False, 'inter_byte_timeout': False} 250 | 251 | >>> pump.simulation = True 252 | 253 | After that one can use the device as usual issuing any commands:: 254 | 255 | >>> pump.is_connected() 256 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Pretending to send message <'/5?23R\r\n'> 257 | True 258 | >>> pump.is_initialized() 259 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Patched send() to return , calling 260 | True 261 | >>> pump.get_valve_position() 262 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Pretending to send message <'/5?6R\r\n'> 263 | >>> pump.get_plunger_position() 264 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Pretending to send message <'/5?R\r\n'> 265 | >>> In [9]: pump.withdraw(200) 266 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Patched send() to return <>, calling 267 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: Waiting done. Device ready. 268 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Pretending to send message <'/5P200R\r\n'> 269 | >>> pump.dispense(200) 270 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Patched send() to return <>, calling 271 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: Waiting done. Device ready. 272 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Pretending to send message <'/5D200R\r\n'> 273 | >>> pump.set_valve_position("O") 274 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Patched send() to return <>, calling 275 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: Waiting done. Device ready. 276 | [INFO] :: PyLabware.controllers.C3000SyringePump.test :: SIM :: Pretending to send message <'/5OR\r\n'> 277 | 278 | All methods work without throwing an error, though, obviously, the 279 | methods that have to return the data from the device do not return anything. 280 | This behavior can be altered in the device driver, see below for more details. 281 | 282 | The simulation mode is designed in such a way to facilitate device testing. Thus, 283 | all value checking in device methods still takes place even in simulation:: 284 | 285 | >>> pump.set_valve_position("X") 286 | SLDeviceCommandError: Unknown valve position requested! 287 | 288 | 289 | Tweaking simulation mode 290 | ************************ 291 | 292 | How simulation mode works 293 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 294 | 295 | Simulation mode works by intercepting the execution workflow in the following four 296 | places: 297 | 298 | * :py:meth:`LabDevice.connect()` 299 | * :py:meth:`LabDevice.disconnect()` 300 | * :py:meth:`LabDevice.send()` 301 | * :py:meth:`LabDevice._recv()` 302 | 303 | A typical implementation just replaces the actual invocation of the underlying 304 | connection adapter method with a :py:meth:`logging.info()` call:: 305 | 306 | def connect(self): 307 | """ Connects to the device. 308 | 309 | This method normally shouldn't be redefined in child classes. 310 | """ 311 | 312 | if self._simulation is True: 313 | self.logger.info("SIM :: Opened connection.") 314 | return 315 | self.connection.open_connection() 316 | self.logger.info("Opened connection.") 317 | 318 | This results in all high-level code (e.g. value checking and other 319 | device-specific logic) to be executed as usual in the simulation mode, but all 320 | the command strings prepared are just logged instead of being sent to the device. 321 | 322 | 323 | Using :py:attr:`@in_simulation_device_returns` decorator 324 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 325 | 326 | Sometimes it is necessary to tune the simulated device behavior more granularly. 327 | The possible examples are: 328 | 329 | * A device that echoes the command back upon successful action. The device driver 330 | code checks the device reply to determine whether the command has been 331 | interpreted and ran correctly and raises an error if not. 332 | 333 | * A device with higher-level logic that relies on a particular value being 334 | returned from the device before the execution can continue, e.g. waiting for a 335 | certain temperature to be reached. 336 | 337 | Both of these examples would be impossible to implement with the simple logic 338 | described above. To work around this issue and avoid patching every complex 339 | device method with ``if self.simulation is True`` clause, a special method decorator is used. 340 | 341 | :py:attr:`@in_simulation_device_returns` decorator should be used to wrap any 342 | function that relies on a particular value that device should return. This value 343 | should be passed as the decorator argument. Here is an example from :doc:`Tricontinent C3000 344 | syringe pump driver `:: 345 | 346 | class C3000SyringePump(AbstractSyringePump, AbstractDistributionValve): 347 | ... 348 | @in_simulation_device_returns(LabDeviceReply(body=C3000SyringePumpCommands.DEFAULT_STATUS)) 349 | def is_idle(self) -> bool: 350 | """Checks if pump is in idle state. 351 | """ 352 | 353 | # Send status request command and read back reply with no parsing 354 | # Parsing manipulates status byte to get error flags, we need it here 355 | try: 356 | 357 | ######################################################## 358 | # send() patching takes place here 359 | ######################################################## 360 | reply = self.send(self.cmd.GET_STATUS, parse_reply=False) 361 | 362 | except SLConnectionError: 363 | return False 364 | # Chop off prefix/terminator & cut out status byte 365 | reply = parser.stripper(reply.body, self.reply_prefix, self.reply_terminator) 366 | status_byte = ord(reply[0]) 367 | # Busy/idle bit is 6th bit of the status byte. 0 - busy, 1 - idle 368 | if status_byte & 1 << 5 == 0: 369 | self.logger.debug("is_idle()::false.") 370 | return False 371 | # Check for errors if any 372 | try: 373 | self.check_errors(status_byte) 374 | except SLDeviceInternalError: 375 | self.logger.debug("is_idle()::false, errors present.") 376 | return False 377 | self.logger.debug("is_idle()::true.") 378 | return True 379 | 380 | The decorator works :doc:`as following `: 381 | 382 | * Gets the object reference from the wrapped bound method (passed as self in the arguments list). 383 | * Checks :py:meth:`self.simulation` to proceed. 384 | * Stores reference to original :py:meth:`self.send()` and replaces :py:meth:`self.send()` with 385 | a lambda returning decorator argument. 386 | * Runs the wrapped function and stores the return value. 387 | * Replaces :py:meth:`self.send()` back with original reference and returns the return 388 | value from previous step. 389 | 390 | Simulating dynamic return values 391 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 392 | 393 | Sometimes just the decorator is also not enough to achieve desirable behavior. A 394 | typical example would be a device that echoes back not the command, but the 395 | command argument and the code that relies on checking this reply for proper 396 | operation, e.g.:: 397 | 398 | Command to device >>> SET_TEMP 25.0 399 | Device reply <<< 25.0 400 | 401 | This is quite typical echo mode often encountered in different devices. In order 402 | to support this mode of operation the following special syntax is used:: 403 | 404 | @in_simulation_device_returns("{$args[1]}") 405 | def some_method(arg1, arg2, arg3): 406 | 407 | ``1`` is the number of positional argument that you want to extract from the 408 | :py:meth:`some_method` call. In the case above the decorator will extract 409 | ``arg2`` from the arguments list and return it as a return value for the 410 | :py:meth:`send()` call. Here's a specific example from the Heidolph overhead 411 | stirrer driver:: 412 | 413 | class HeiTorque100PrecisionStirrer(AbstractStirringController): 414 | ... 415 | @in_simulation_device_returns("{$args[1]}") 416 | def set_speed(self, speed: int): 417 | """Sets rotation speed in rpm. 418 | """ 419 | 420 | # If the stirrer is not running, just update internal variable 421 | if not self._running: 422 | # Check value against limits before updating 423 | self.check_value(self.cmd.SET_SPEED, speed) 424 | self._speed_setpoint = speed 425 | else: 426 | 427 | ############################################################### 428 | # Here send() will be replaced with a lambda returning args[1] 429 | # from set_speed(), which is speed 430 | ############################################################### 431 | readback_setpoint = self.send(self.cmd.SET_SPEED, speed) 432 | 433 | if readback_setpoint != speed: 434 | self.stop() 435 | raise SLDeviceReplyError(f"Error setting stirrer speed. Requested setpoint <{self._speed_setpoint}> " 436 | f"RPM, read back setpoint <{readback_setpoint}> RPM") 437 | self._speed_setpoint = speed 438 | 439 | 440 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | OpenAPI parser 5 | -------------- 6 | 7 | The parser provided in :file:`utils/openapi_parser.py` provides the basic minimal 8 | functionality to convert OpenAPI specifications in JSON/YAML format into the 9 | PyLabware compatible dictionary-based command definitions. 10 | 11 | The usage instructions can be found by running the parser without arguments:: 12 | 13 | python openapi_parser.py 14 | -------------------------------------------------------------------------------- /manuals/Vacubrand_CVC3000.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f8903304b0e9ea4d270fb686afe151aef11fcbe44d74ab2e0765b6cec1ff101f 3 | size 23256537 4 | -------------------------------------------------------------------------------- /manuals/heidolph_hei_torque_precision.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:12a64744e6ca5609c4a1a8aec8cc7bab8fc943490f9a50499290312b0a68f2af 3 | size 3273151 4 | -------------------------------------------------------------------------------- /manuals/heidolph_rzr_2052_control.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:10aac710299e90c7392a0d67f1285b2a3af5be0d74adc9da78b202d07150e8d1 3 | size 1301592 4 | -------------------------------------------------------------------------------- /manuals/huber_thermostats.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:84628e9bf242f3e13f520d535c405efa39e22ed0b562b579c5fdc3106ebe8032 3 | size 2811298 4 | -------------------------------------------------------------------------------- /manuals/idex_mx_ii.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4498b029e9768905150d9fdf010d2e021fab689c3aba6cf13d524b21a72e2455 3 | size 379357 4 | -------------------------------------------------------------------------------- /manuals/ika_microstar_75.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4672f4f506a2d6f50b01c16f92db81f4af0f1f47819fea58db8fa6e5b380f881 3 | size 644882 4 | -------------------------------------------------------------------------------- /manuals/ika_rct_digital.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:efa6aaa36dd10039dd6ba0eb206b73da4721037e6f102ff81f6746f7a3c2f8e2 3 | size 6576016 4 | -------------------------------------------------------------------------------- /manuals/ika_ret_control_visc.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d81a3ac5d047cbe3b417df985d4e2fc5e04ed69a0bc73a719aab30ed01e7cff0 3 | size 6841669 4 | -------------------------------------------------------------------------------- /manuals/ika_rv10.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:565214eed7b95468cdd2699bda612ddaa4cffd69599782d5d27e8d4b4a11e30a 3 | size 6715923 4 | -------------------------------------------------------------------------------- /manuals/ika_rv10_heating_bath.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:e81569dd9529696c9ea69e70370473681fd899892aef9ba852b00c5405707c9e 3 | size 2189572 4 | -------------------------------------------------------------------------------- /manuals/julabo_cf41.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:894931e377dc39fd9ef0e1745c60479225cad274074b4fc470df620966907b22 3 | size 6433243 4 | -------------------------------------------------------------------------------- /manuals/tricontinent_c_series.pdf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1f8fa38c78eca28679f92c711afa67bc4eb3b3fd8f62b62a9395c231177b2e96 3 | size 7278011 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | VERSION = '1.0' 4 | NAME = 'PyLabware' 5 | 6 | setup( 7 | name=NAME, 8 | version=VERSION, 9 | install_requires=['pyserial>=3.3', 'pyyaml>=5.0', 'requests>=2.23'], 10 | package_data={'PyLabware': ['manuals/*']}, 11 | include_package_data=True, 12 | packages=find_packages(), 13 | zip_safe=True, 14 | author='Sergey Zalesskiy', 15 | author_email='s.zalesskiy@gmail.com', 16 | license='See LICENSE', 17 | description='A library to control common chemical laboratory hardware.', 18 | long_description='', 19 | platforms=['windows'], 20 | ) 21 | --------------------------------------------------------------------------------