mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
Initial commit
This commit is contained in:
388
unilabos/devices/pump_and_valve/runze_backbone.py
Normal file
388
unilabos/devices/pump_and_valve/runze_backbone.py
Normal file
@@ -0,0 +1,388 @@
|
||||
import asyncio
|
||||
from threading import Lock, Event
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from typing import Any, Union, Optional, overload
|
||||
|
||||
import serial.tools.list_ports
|
||||
from serial import Serial
|
||||
from serial.serialutil import SerialException
|
||||
|
||||
|
||||
class RunzeSyringePumpMode(Enum):
|
||||
Normal = 0
|
||||
AccuratePos = 1
|
||||
AccuratePosVel = 2
|
||||
|
||||
|
||||
pulse_freq_grades = {
|
||||
6000: "0" ,
|
||||
5600: "1" ,
|
||||
5000: "2" ,
|
||||
4400: "3" ,
|
||||
3800: "4" ,
|
||||
3200: "5" ,
|
||||
2600: "6" ,
|
||||
2200: "7" ,
|
||||
2000: "8" ,
|
||||
1800: "9" ,
|
||||
1600: "10",
|
||||
1400: "11",
|
||||
1200: "12",
|
||||
1000: "13",
|
||||
800 : "14",
|
||||
600 : "15",
|
||||
400 : "16",
|
||||
200 : "17",
|
||||
190 : "18",
|
||||
180 : "19",
|
||||
170 : "20",
|
||||
160 : "21",
|
||||
150 : "22",
|
||||
140 : "23",
|
||||
130 : "24",
|
||||
120 : "25",
|
||||
110 : "26",
|
||||
100 : "27",
|
||||
90 : "28",
|
||||
80 : "29",
|
||||
70 : "30",
|
||||
60 : "31",
|
||||
50 : "32",
|
||||
40 : "33",
|
||||
30 : "34",
|
||||
20 : "35",
|
||||
18 : "36",
|
||||
16 : "37",
|
||||
14 : "38",
|
||||
12 : "39",
|
||||
10 : "40",
|
||||
}
|
||||
|
||||
|
||||
class RunzeSyringePumpConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RunzeSyringePumpInfo:
|
||||
port: str
|
||||
address: str = "1"
|
||||
|
||||
max_volume: float = 25.0
|
||||
mode: RunzeSyringePumpMode = RunzeSyringePumpMode.Normal
|
||||
|
||||
def create(self):
|
||||
return RunzeSyringePump(self.port, self.address, self.max_volume, self.mode)
|
||||
|
||||
|
||||
class RunzeSyringePump:
|
||||
def __init__(self, port: str, address: str = "1", max_volume: float = 25.0, mode: RunzeSyringePumpMode = None):
|
||||
self.port = port
|
||||
self.address = address
|
||||
|
||||
self.max_volume = max_volume
|
||||
self.total_steps = self.total_steps_vel = 6000
|
||||
|
||||
self._status = "Idle"
|
||||
self._mode = mode
|
||||
self._max_velocity = 0
|
||||
self._valve_position = "I"
|
||||
self._position = 0
|
||||
|
||||
try:
|
||||
# if port in serial_ports and serial_ports[port].is_open:
|
||||
# self.hardware_interface = serial_ports[port]
|
||||
# else:
|
||||
# serial_ports[port] = self.hardware_interface = Serial(
|
||||
# baudrate=9600,
|
||||
# port=port
|
||||
# )
|
||||
self.hardware_interface = Serial(
|
||||
baudrate=9600,
|
||||
port=port
|
||||
)
|
||||
|
||||
except (OSError, SerialException) as e:
|
||||
# raise RunzeSyringePumpConnectionError from e
|
||||
self.hardware_interface = port
|
||||
|
||||
self._busy = False
|
||||
self._closing = False
|
||||
self._error_event = Event()
|
||||
self._query_lock = Lock()
|
||||
self._run_lock = Lock()
|
||||
|
||||
def _adjust_total_steps(self):
|
||||
self.total_steps = 6000 if self.mode == RunzeSyringePumpMode.Normal else 48000
|
||||
self.total_steps_vel = 48000 if self.mode == RunzeSyringePumpMode.AccuratePosVel else 6000
|
||||
|
||||
def send_command(self, full_command: str):
|
||||
full_command_data = bytearray(full_command, 'ascii')
|
||||
response = self.hardware_interface.write(full_command_data)
|
||||
time.sleep(0.05)
|
||||
output = self._receive(self.hardware_interface.read_until(b"\n"))
|
||||
return output
|
||||
|
||||
def _query(self, command: str):
|
||||
with self._query_lock:
|
||||
if self._closing:
|
||||
raise RunzeSyringePumpConnectionError
|
||||
|
||||
run = 'R' if not "?" in command else ''
|
||||
full_command = f"/{self.address}{command}{run}\r\n"
|
||||
|
||||
output = self.send_command(full_command)[3:-3]
|
||||
return output
|
||||
|
||||
def _parse(self, data: bytes, dtype: Optional[type] = None):
|
||||
response = data.decode()
|
||||
|
||||
if dtype == bool:
|
||||
return response == "1"
|
||||
elif dtype == int:
|
||||
return int(response)
|
||||
else:
|
||||
return response
|
||||
|
||||
def _receive(self, data: bytes):
|
||||
ascii_string = "".join(chr(byte) for byte in data)
|
||||
was_busy = self._busy
|
||||
self._busy = ((data[0] & (1 << 5)) < 1) or ascii_string.startswith("@")
|
||||
return ascii_string
|
||||
|
||||
def _run(self, command: str):
|
||||
with self._run_lock:
|
||||
try:
|
||||
response = self._query(command)
|
||||
while True:
|
||||
time.sleep(0.5) # Wait for 0.5 seconds before polling again
|
||||
|
||||
status = self.get_status()
|
||||
if status == 'Idle':
|
||||
break
|
||||
finally:
|
||||
pass
|
||||
return response
|
||||
|
||||
def initialize(self):
|
||||
print("Initializing Runze Syringe Pump")
|
||||
response = self._run("Z")
|
||||
# if self.mode:
|
||||
# self.set_mode(self.mode)
|
||||
# else:
|
||||
# # self.mode = RunzeSyringePumpMode.Normal
|
||||
# # self.set_mode(self.mode)
|
||||
# self.mode = self.get_mode()
|
||||
return response
|
||||
|
||||
# Settings
|
||||
|
||||
def set_baudrate(self, baudrate):
|
||||
if baudrate == 9600:
|
||||
return self._run("U41")
|
||||
elif baudrate == 38400:
|
||||
return self._run("U47")
|
||||
else:
|
||||
raise ValueError("Unsupported baudrate")
|
||||
|
||||
# Device Status
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
def _standardize_status(self, status_raw):
|
||||
return "Idle" if status_raw == "`" else "Busy"
|
||||
|
||||
def get_status(self):
|
||||
status_raw = self._query("Q")
|
||||
self._status = self._standardize_status(status_raw)
|
||||
return self._status
|
||||
|
||||
# Mode Settings and Queries
|
||||
|
||||
@property
|
||||
def mode(self) -> int:
|
||||
return self._mode
|
||||
|
||||
# def set_mode(self, mode: RunzeSyringePumpMode):
|
||||
# self.mode = mode
|
||||
# self._adjust_total_steps()
|
||||
# command = f"N{mode.value}"
|
||||
# return self._run(command)
|
||||
|
||||
# def get_mode(self):
|
||||
# response = self._query("?28")
|
||||
# status_raw, mode = response[0], int(response[1])
|
||||
# self.mode = RunzeSyringePumpMode._value2member_map_[mode]
|
||||
# self._adjust_total_steps()
|
||||
# return self.mode
|
||||
|
||||
# Speed Settings and Queries
|
||||
|
||||
@property
|
||||
def max_velocity(self) -> float:
|
||||
return self._max_velocity
|
||||
|
||||
def set_max_velocity(self, velocity: float):
|
||||
self._max_velocity = velocity
|
||||
pulse_freq = int(velocity / self.max_volume * self.total_steps_vel)
|
||||
pulse_freq = min(6000, pulse_freq)
|
||||
return self._run(f"V{pulse_freq}")
|
||||
|
||||
def get_max_velocity(self):
|
||||
response = self._query("?2")
|
||||
status_raw, pulse_freq = response[0], int(response[1:])
|
||||
self._status = self._standardize_status(status_raw)
|
||||
self._max_velocity = pulse_freq / self.total_steps_vel * self.max_volume
|
||||
return self._max_velocity
|
||||
|
||||
def set_velocity_grade(self, velocity: Union[int, str]):
|
||||
return self._run(f"S{velocity}")
|
||||
|
||||
def get_velocity_grade(self):
|
||||
response = self._query("?2")
|
||||
status_raw, pulse_freq = response[0], int(response[1:])
|
||||
g = "-1"
|
||||
for freq, grade in pulse_freq_grades.items():
|
||||
if pulse_freq >= freq:
|
||||
g = grade
|
||||
break
|
||||
return g
|
||||
|
||||
def get_velocity_init(self):
|
||||
response = self._query("?1")
|
||||
status_raw, pulse_freq = response[0], int(response[1:])
|
||||
self._status = self._standardize_status(status_raw)
|
||||
velocity = pulse_freq / self.total_steps_vel * self.max_volume
|
||||
return pulse_freq, velocity
|
||||
|
||||
def get_velocity_end(self):
|
||||
response = self._query("?3")
|
||||
status_raw, pulse_freq = response[0], int(response[1:])
|
||||
self._status = self._standardize_status(status_raw)
|
||||
velocity = pulse_freq / self.total_steps_vel * self.max_volume
|
||||
return pulse_freq, velocity
|
||||
|
||||
# Operations
|
||||
|
||||
# Valve Setpoint and Queries
|
||||
|
||||
@property
|
||||
def valve_position(self) -> str:
|
||||
return self._valve_position
|
||||
|
||||
def set_valve_position(self, position: Union[int, str, float]):
|
||||
if type(position) == float:
|
||||
position = round(position / 120)
|
||||
command = f"I{position}" if type(position) == int or ord(position) <= 57 else position.upper()
|
||||
response = self._run(command)
|
||||
self._valve_position = f"{position}" if type(position) == int or ord(position) <= 57 else position.upper()
|
||||
return response
|
||||
|
||||
def get_valve_position(self) -> str:
|
||||
response = self._query("?6")
|
||||
status_raw, pos_valve = response[0], response[1].upper()
|
||||
self._valve_position = pos_valve
|
||||
self._status = self._standardize_status(status_raw)
|
||||
return pos_valve
|
||||
|
||||
# Plunger Setpoint and Queries
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
def get_position(self):
|
||||
response = self._query("?0")
|
||||
status_raw, pos_step = response[0], int(response[1:])
|
||||
self._status = self._standardize_status(status_raw)
|
||||
return pos_step / self.total_steps * self.max_volume
|
||||
|
||||
def set_position(self, position: float, max_velocity: float = None):
|
||||
"""
|
||||
Move to absolute volume (unit: ml)
|
||||
|
||||
Args:
|
||||
position (float): absolute position of the plunger, unit: ml
|
||||
max_velocity (float): maximum velocity of the plunger, unit: ml/s
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if max_velocity is not None:
|
||||
self.set_max_velocity(max_velocity)
|
||||
pulse_freq = int(max_velocity / self.max_volume * self.total_steps_vel)
|
||||
pulse_freq = min(6000, pulse_freq)
|
||||
velocity_cmd = f"V{pulse_freq}"
|
||||
else:
|
||||
velocity_cmd = ""
|
||||
pos_step = int(position / self.max_volume * self.total_steps)
|
||||
return self._run(f"{velocity_cmd}A{pos_step}")
|
||||
|
||||
def pull_plunger(self, volume: float):
|
||||
"""
|
||||
Pull a fixed volume (unit: ml)
|
||||
|
||||
Args:
|
||||
volume (float): absolute position of the plunger, unit: mL
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
pos_step = int(volume / self.max_volume * self.total_steps)
|
||||
return self._run(f"P{pos_step}")
|
||||
|
||||
def push_plunger(self, volume: float):
|
||||
"""
|
||||
Push a fixed volume (unit: ml)
|
||||
|
||||
Args:
|
||||
volume (float): absolute position of the plunger, unit: mL
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
pos_step = int(volume / self.max_volume * self.total_steps)
|
||||
return self._run(f"D{pos_step}")
|
||||
|
||||
def get_plunger_position(self):
|
||||
response = self._query("?4")
|
||||
status, pos_step = response[0], int(response[1:])
|
||||
return pos_step / self.total_steps * self.max_volume
|
||||
|
||||
def stop_operation(self):
|
||||
return self._run("T")
|
||||
|
||||
# Queries
|
||||
|
||||
def query_command_buffer_status(self):
|
||||
return self._query("?10")
|
||||
|
||||
def query_backlash_position(self):
|
||||
return self._query("?12")
|
||||
|
||||
def query_aux_input_status_1(self):
|
||||
return self._query("?13")
|
||||
|
||||
def query_aux_input_status_2(self):
|
||||
return self._query("?14")
|
||||
|
||||
def query_software_version(self):
|
||||
return self._query("?23")
|
||||
|
||||
def wait_error(self):
|
||||
self._error_event.wait()
|
||||
|
||||
def close(self):
|
||||
if self._closing:
|
||||
raise RunzeSyringePumpConnectionError
|
||||
|
||||
self._closing = True
|
||||
self.hardware_interface.close()
|
||||
|
||||
@staticmethod
|
||||
def list():
|
||||
for item in serial.tools.list_ports.comports():
|
||||
yield RunzeSyringePumpInfo(port=item.device)
|
||||
Reference in New Issue
Block a user