Merge branch 'refs/heads/main' into dev

This commit is contained in:
wznln
2025-04-29 10:04:56 +08:00
13 changed files with 118 additions and 81 deletions

View File

@@ -25,21 +25,6 @@ Uni-Lab 操作系统是一个用于实验室自动化的综合平台,旨在连
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 安装指南
请先克隆此仓库:
```bash
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
```
然后安装 Uni-Lab-OS:
```bash
pip install .
```
## 快速开始
1. 配置Conda环境
@@ -52,17 +37,30 @@ mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab
# 或更新现有环境
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, 或 `osx-arm64`。
conda env update --file unilabos-[YOUR_OS].yml -n 环境名
# 现阶段,需要安装 `unilabos_msgs` 包
# 可以前往 Release 页面下载系统对应的包进行安装
conda install ros-humble-unilabos-msgs-0.8.0-xxxxx.tar.bz2
# 安装PyLabRobot等前置
git clone https://github.com/PyLabRobot/pylabrobot
pip install .[opentrons]
```
其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, 或 `osx-arm64`
2. 安装 Uni-Lab-OS:
```bash
# 克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
2. 启动 Uni-Lab 系统:
# 安装 Uni-Lab-OS
pip install .
```
3. 启动 Uni-Lab 系统:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)

View File

@@ -6,7 +6,7 @@ def generate_clean_protocol(
G: nx.DiGraph,
vessel: str, # Vessel to clean.
solvent: str, # Solvent to clean vessel with.
volume: float = 25000.0, # Optional. Volume of solvent to clean vessel with.
volume: float = 25.0, # Optional. Volume of solvent to clean vessel with.
temp: float = 25, # Optional. Temperature to heat vessel to while cleaning.
repeats: int = 1, # Optional. Number of cleaning cycles to perform.
) -> list[dict]:
@@ -27,7 +27,7 @@ def generate_clean_protocol(
from_vessel = f"flask_{solvent}"
waste_vessel = f"waste_workup"
transfer_flowrate = flowrate = 2500.0
transfer_flowrate = flowrate = 2.5
# 生成泵操作的动作序列
for i in range(repeats):

View File

@@ -24,8 +24,8 @@ def generate_evaporate_protocol(
# 生成泵操作的动作序列
pump_action_sequence = []
reactor_volume = 500000.0
transfer_flowrate = flowrate = 2500.0
reactor_volume = 500.0
transfer_flowrate = flowrate = 2.5
# 开启冷凝器
pump_action_sequence.append({

View File

@@ -7,7 +7,7 @@ def generate_pump_protocol(
from_vessel: str,
to_vessel: str,
volume: float,
flowrate: float = 500.0,
flowrate: float = 0.5,
transfer_flowrate: float = 0,
) -> list[dict]:
"""
@@ -141,11 +141,11 @@ def generate_pump_protocol_with_rinsing(
time: float = 0,
viscous: bool = False,
rinsing_solvent: str = "air",
rinsing_volume: float = 5000.0,
rinsing_volume: float = 5.0,
rinsing_repeats: int = 2,
solid: bool = False,
flowrate: float = 2500.0,
transfer_flowrate: float = 500.0,
flowrate: float = 2.5,
transfer_flowrate: float = 0.5,
) -> list[dict]:
"""
Generates a pump protocol for transferring a specified volume between vessels, including rinsing steps with a chosen solvent. This function constructs a sequence of pump actions based on the provided parameters and the shortest path in a directed graph.
@@ -159,11 +159,11 @@ def generate_pump_protocol_with_rinsing(
time (float, optional): Time over which to perform the transfer (default is 0).
viscous (bool, optional): Indicates if the fluid is viscous (default is False).
rinsing_solvent (str, optional): The solvent to use for rinsing (default is "air").
rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5000.0).
rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5.0).
rinsing_repeats (int, optional): The number of times to repeat rinsing (default is 2).
solid (bool, optional): Indicates if the transfer involves a solid (default is False).
flowrate (float, optional): The flow rate for the transfer (default is 2500.0). 最终注入容器B时的流速
transfer_flowrate (float, optional): The flow rate for the transfer action (default is 500.0). 泵骨架中转移流速(若不指定,默认与注入流速相同)
flowrate (float, optional): The flow rate for the transfer (default is 2.5). 最终注入容器B时的流速
transfer_flowrate (float, optional): The flow rate for the transfer action (default is 0.5). 泵骨架中转移流速(若不指定,默认与注入流速相同)
Returns:
list[dict]: A sequence of pump actions to be executed for the transfer and rinsing process. 泵操作的动作序列.
@@ -172,7 +172,7 @@ def generate_pump_protocol_with_rinsing(
AssertionError: If the number of rinsing solvents does not match the number of rinsing repeats.
Examples:
pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 100.0, rinsing_solvent="water")
pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 0.1, rinsing_solvent="water")
"""
air_vessel = "flask_air"
waste_vessel = f"waste_workup"

View File

@@ -11,7 +11,7 @@ def generate_separate_protocol(
to_vessel: str, # Vessel to send product phase to.
waste_phase_to_vessel: str, # Optional. Vessel to send waste phase to.
solvent: str, # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases.
solvent_volume: float = 50000, # Optional. Volume of solvent to add.
solvent_volume: float = 50, # Optional. Volume of solvent to add (mL).
through: str = "", # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'.
repeats: int = 1, # Optional. Number of separations to perform.
stir_time: float = 30, # Optional. Time stir for after adding solvent, before separation of phases.
@@ -32,7 +32,7 @@ def generate_separate_protocol(
# 生成泵操作的动作序列
pump_action_sequence = []
reactor_volume = 500000.0
reactor_volume = 500.0
waste_vessel = waste_phase_to_vessel
# TODO通过物料管理系统找到溶剂的容器
@@ -46,7 +46,7 @@ def generate_separate_protocol(
separator_controller = f"{separation_vessel}_controller"
separation_vessel_bottom = f"flask_{separation_vessel}"
transfer_flowrate = flowrate = 2500.0
transfer_flowrate = flowrate = 2.5
if from_vessel != separation_vessel:
pump_action_sequence.append(
@@ -140,8 +140,8 @@ def generate_separate_protocol(
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": to_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
"volume": 250.0,
"time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
@@ -164,8 +164,8 @@ def generate_separate_protocol(
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": waste_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
"volume": 250.0,
"time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
@@ -179,8 +179,8 @@ def generate_separate_protocol(
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": waste_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
"volume": 250.0,
"time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
@@ -203,8 +203,8 @@ def generate_separate_protocol(
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": to_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
"volume": 250.0,
"time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
@@ -221,8 +221,8 @@ def generate_separate_protocol(
"action_kwargs": {
"from_vessel": to_vessel,
"to_vessel": separation_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
"volume": 250.0,
"time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}

View File

@@ -75,20 +75,29 @@ def _update_config_from_module(module):
# 需要先判断是否为相对路径
if MQConfig.ca_file.startswith("."):
MQConfig.ca_file = os.path.join(BasicConfig.config_path, MQConfig.ca_file)
with open(MQConfig.ca_file, "r", encoding="utf-8") as f:
MQConfig.ca_content = f.read()
if len(MQConfig.ca_file) != 0:
with open(MQConfig.ca_file, "r", encoding="utf-8") as f:
MQConfig.ca_content = f.read()
else:
logger.warning("Skipping CA file loading, ca_file is empty")
if len(MQConfig.cert_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.cert_file.startswith("."):
MQConfig.cert_file = os.path.join(BasicConfig.config_path, MQConfig.cert_file)
with open(MQConfig.cert_file, "r", encoding="utf-8") as f:
MQConfig.cert_content = f.read()
if len(MQConfig.ca_file) != 0:
with open(MQConfig.cert_file, "r", encoding="utf-8") as f:
MQConfig.cert_content = f.read()
else:
logger.warning("Skipping cert file loading, cert_file is empty")
if len(MQConfig.key_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.key_file.startswith("."):
MQConfig.key_file = os.path.join(BasicConfig.config_path, MQConfig.key_file)
with open(MQConfig.key_file, "r", encoding="utf-8") as f:
MQConfig.key_content = f.read()
if len(MQConfig.ca_file) != 0:
with open(MQConfig.key_file, "r", encoding="utf-8") as f:
MQConfig.key_content = f.read()
else:
logger.warning("Skipping key file loading, key_file is empty")
def load_config(config_path=None):

View File

@@ -1,7 +1,7 @@
serial:
description: Serial communication interface, used when sharing same serial port for multiple devices
class:
module: unilabos.ros.nodes.presets:ROS2SerialNode
module: unilabos.ros.nodes.presets.serial_node:ROS2SerialNode
type: ros2
schema:
properties: {}

View File

@@ -1,7 +1,7 @@
separator.homemade:
description: Separator device with homemade grbl controller
class:
module: unilabos.devices.separator.homemade_grbl_conductivity:Separator_Controller
module: unilabos.devices.separator.homemade_grbl_conductivity:SeparatorController
type: python
status_types:
sensordata: Float64

View File

@@ -3,6 +3,10 @@ syringe_pump_with_valve.runze:
class:
module: unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump
type: python
hardware_interface:
name: hardware_interface
read: send_command
write: send_command
schema:
type: object
properties:

View File

@@ -342,9 +342,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else:
return getattr(self.driver_instance, attr_name)
except AttributeError as ex:
self.lab_logger().error(
f"publish error, {str(type(self.driver_instance))[8:-2]} has no attribute '{attr_name}'"
)
if ex.args[0].startswith(f"AttributeError: '{self.driver_instance.__class__.__name__}' object"):
self.lab_logger().error(
f"publish error, {str(type(self.driver_instance))[8:-2]} has no attribute '{attr_name}'"
)
else:
self.lab_logger().error(
f"publish error, when {str(type(self.driver_instance))[8:-2]} getting attribute '{attr_name}'"
)
self.lab_logger().error(traceback.format_exc())
self._property_publishers[attr_name] = PropertyPublisher(
self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish
@@ -430,6 +436,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info(f"同步执行动作 {ACTION}")
future = self._executor.submit(ACTION, **action_kwargs)
def _handle_future_exception(fut):
try:
fut.result()
except Exception as e:
error(f"同步任务 {ACTION.__name__} 报错了")
error(traceback.format_exc())
future.add_done_callback(_handle_future_exception)
action_type = action_value_mapping["type"]
feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
result_msg_types = action_type.Result.get_fields_and_field_types()

View File

@@ -1,6 +1,7 @@
import copy
import threading
import time
import traceback
import uuid
from typing import Optional, Dict, Any, List, ClassVar, Set
@@ -132,10 +133,14 @@ class HostNode(BaseROS2DeviceNode):
controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config)
for bridge in self.bridges:
if hasattr(bridge, "resource_add"):
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.")
bridge.resource_add(add_schema(resources_config))
try:
for bridge in self.bridges:
if hasattr(bridge, "resource_add"):
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.")
bridge.resource_add(add_schema(resources_config))
except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc())
# 创建定时器,定期发现设备
self._discovery_timer = self.create_timer(

View File

@@ -20,7 +20,7 @@ from unilabos.ros.msgs.message_converter import (
convert_from_ros_msg,
convert_from_ros_msg_with_mapping,
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode
class ROS2ProtocolNode(BaseROS2DeviceNode):
@@ -55,34 +55,39 @@ class ROS2ProtocolNode(BaseROS2DeviceNode):
)
# 初始化子设备
communication_node_id = None
self.communication_node_id_to_instance = {}
for device_id, device_config in self.children.items():
if device_config.get("type", "device") != "device":
self.lab_logger().debug(f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping.")
continue
d = self.initialize_device(device_id, device_config)
try:
d = self.initialize_device(device_id, device_config)
except Exception as ex:
self.lab_logger().error(f"[Protocol Node] Failed to initialize device {device_id}: {ex}")
d = None
if d is None:
continue
if "serial_" in device_id or "io_" in device_id:
communication_node_id = device_id
self.communication_node_id_to_instance[device_id] = d
continue
# 设置硬件接口代理
if d and hasattr(d, "_hardware_interface"):
if d:
if (
hasattr(d, d._hardware_interface["name"])
and hasattr(d, d._hardware_interface["write"])
and (d._hardware_interface["read"] is None or hasattr(d, d._hardware_interface["read"]))
hasattr(d.driver_instance, d.ros_node_instance._hardware_interface["name"])
and hasattr(d.driver_instance, d.ros_node_instance._hardware_interface["write"])
and (d.ros_node_instance._hardware_interface["read"] is None or hasattr(d.driver_instance, d.ros_node_instance._hardware_interface["read"]))
):
name = getattr(d, d._hardware_interface["name"])
read = d._hardware_interface.get("read", None)
write = d._hardware_interface.get("write", None)
name = getattr(d.driver_instance, d.ros_node_instance._hardware_interface["name"])
read = d.ros_node_instance._hardware_interface.get("read", None)
write = d.ros_node_instance._hardware_interface.get("write", None)
# 如果硬件接口是字符串,通过通信设备提供
if isinstance(name, str) and communication_node_id in self.sub_devices:
self._setup_hardware_proxy(d, self.sub_devices[communication_node_id], read, write)
if isinstance(name, str) and name in self.sub_devices:
self._setup_hardware_proxy(d, self.sub_devices[name], read, write)
def _setup_protocol_names(self, protocol_type):
# 处理协议类型
@@ -234,11 +239,11 @@ class ROS2ProtocolNode(BaseROS2DeviceNode):
"""还没有改过的部分"""
def _setup_hardware_proxy(self, device, communication_device, read_method, write_method):
def _setup_hardware_proxy(self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method):
"""为设备设置硬件接口代理"""
extra_info = [getattr(device, info) for info in communication_device._hardware_interface.get("extra_info", [])]
write_func = getattr(communication_device, communication_device._hardware_interface["write"])
read_func = getattr(communication_device, communication_device._hardware_interface["read"])
extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])]
write_func = getattr(communication_device.ros_node_instance, communication_device.ros_node_instance._hardware_interface["write"])
read_func = getattr(communication_device.ros_node_instance, communication_device.ros_node_instance._hardware_interface["read"])
def _read():
return read_func(*extra_info)
@@ -247,9 +252,9 @@ class ROS2ProtocolNode(BaseROS2DeviceNode):
return write_func(*extra_info, command)
if read_method:
setattr(device, read_method, _read)
setattr(device.driver_instance, read_method, _read)
if write_method:
setattr(device, write_method, _write)
setattr(device.driver_instance, write_method, _write)
async def _update_resources(self, goal, protocol_kwargs):

View File

@@ -21,6 +21,7 @@ class ROS2SerialNode(BaseROS2DeviceNode):
self.hardware_interface = Serial(baudrate=baudrate, port=port)
except (OSError, SerialException) as e:
# 因为还没调用父类初始化,无法使用日志,直接抛出异常
# print(f"Failed to connect to serial port {port} at {baudrate} baudrate.")
raise RuntimeError(f"Failed to connect to serial port {port} at {baudrate} baudrate.") from e
# 初始化BaseROS2DeviceNode使用自身作为driver_instance
@@ -46,7 +47,7 @@ class ROS2SerialNode(BaseROS2DeviceNode):
self.lab_logger().info(f"【ROS2SerialNode.__init__】创建串口写入服务: serialwrite")
def send_command(self, command: str):
self.lab_logger().info(f"【ROS2SerialNode.send_command】发送命令: {command}")
# self.lab_logger().debug(f"【ROS2SerialNode.send_command】发送命令: {command}")
with self._query_lock:
if self._closing:
self.lab_logger().error(f"【ROS2SerialNode.send_command】设备正在关闭无法发送命令")
@@ -58,23 +59,23 @@ class ROS2SerialNode(BaseROS2DeviceNode):
response = self.hardware_interface.write(full_command_data)
# time.sleep(0.05)
output = self._receive(self.hardware_interface.read_until(b"\n"))
self.lab_logger().info(f"【ROS2SerialNode.send_command】接收响应: {output}")
# self.lab_logger().debug(f"【ROS2SerialNode.send_command】接收响应: {output}")
return output
def read_data(self):
self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取数据")
# self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取数据")
with self._query_lock:
if self._closing:
self.lab_logger().error(f"【ROS2SerialNode.read_data】设备正在关闭无法读取数据")
raise RuntimeError
data = self.hardware_interface.read_until(b"\n")
result = self._receive(data)
self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取到数据: {result}")
# self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取到数据: {result}")
return result
def _receive(self, data: bytes):
ascii_string = "".join(chr(byte) for byte in data)
self.lab_logger().debug(f"【ROS2SerialNode._receive】接收数据: {ascii_string}")
# self.lab_logger().debug(f"【ROS2SerialNode._receive】接收数据: {ascii_string}")
return ascii_string
def handle_serial_request(self, request, response):