Compare commits

...

13 Commits

Author SHA1 Message Date
KCFeng425
31993594e6 大图的问题都修复好了,添加了gassource和vacuum pump的驱动以及注册表 2025-06-16 14:39:55 +08:00
Xuwznln
6c471553c4 fix container value
add parent_name to edge device id
2025-06-16 12:45:26 +08:00
Xuwznln
e193bc493c Merge branch 'device-registry-port' of https://github.com/KCFeng425/Uni-Lab-OS into fork/KCFeng425/device-registry-port 2025-06-16 12:16:05 +08:00
Xuwznln
9f8f6e55c4 add virtual_separator virtual_rotavap
fix transfer_pump
2025-06-16 12:15:54 +08:00
Junhan Chang
8d56c523bb update container registry and handles 2025-06-16 12:15:39 +08:00
Xuwznln
57cb120c8c add resource edge upload 2025-06-16 11:51:02 +08:00
Xuwznln
a303bd7c5b bump version to 0.9.6 2025-06-16 10:59:35 +08:00
Xuwznln
47e58e13c7 Merge remote-tracking branch 'origin/dev' into fork/KCFeng425/device-registry-port 2025-06-16 10:01:27 +08:00
Junhan Chang
5b9e13555c Fix handles 2025-06-16 08:03:06 +08:00
Xuwznln
f7db8d17c5 container 添加和更新完成 2025-06-15 17:37:38 +08:00
Xuwznln
a354965f8e Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-06-15 12:51:48 +08:00
Xuwznln
934276d2f7 create container 2025-06-15 12:51:37 +08:00
Harvey Que
803809480b hotfix: Add .certs in .gitignore 2025-06-15 09:09:06 +08:00
31 changed files with 1182 additions and 276 deletions

4
.gitignore vendored
View File

@@ -234,3 +234,7 @@ CATKIN_IGNORE
*.graphml *.graphml
unilabos/device_mesh/view_robot.rviz unilabos/device_mesh/view_robot.rviz
# Certs
**/.certs

View File

@@ -49,7 +49,7 @@ conda env update --file unilabos-[YOUR_OS].yml -n environment_name
# Currently, you need to install the `unilabos_msgs` package # Currently, you need to install the `unilabos_msgs` package
# You can download the system-specific package from the Release page # You can download the system-specific package from the Release page
conda install ros-humble-unilabos-msgs-0.9.5-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.6-xxxxx.tar.bz2
# Install PyLabRobot and other prerequisites # Install PyLabRobot and other prerequisites
git clone https://github.com/PyLabRobot/pylabrobot plr_repo git clone https://github.com/PyLabRobot/pylabrobot plr_repo

View File

@@ -49,7 +49,7 @@ conda env update --file unilabos-[YOUR_OS].yml -n 环境名
# 现阶段,需要安装 `unilabos_msgs` 包 # 现阶段,需要安装 `unilabos_msgs` 包
# 可以前往 Release 页面下载系统对应的包进行安装 # 可以前往 Release 页面下载系统对应的包进行安装
conda install ros-humble-unilabos-msgs-0.9.5-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.6-xxxxx.tar.bz2
# 安装PyLabRobot等前置 # 安装PyLabRobot等前置
git clone https://github.com/PyLabRobot/pylabrobot plr_repo git clone https://github.com/PyLabRobot/pylabrobot plr_repo

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.9.5 version: 0.9.6
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
folder: ros-humble-unilabos-msgs/src/work folder: ros-humble-unilabos-msgs/src/work
@@ -50,12 +50,12 @@ requirements:
- robostack-staging::ros-humble-rosidl-default-generators - robostack-staging::ros-humble-rosidl-default-generators
- robostack-staging::ros-humble-std-msgs - robostack-staging::ros-humble-std-msgs
- robostack-staging::ros-humble-geometry-msgs - robostack-staging::ros-humble-geometry-msgs
- robostack-staging::ros2-distro-mutex=0.6.* - robostack-staging::ros2-distro-mutex=0.5.*
run: run:
- robostack-staging::ros-humble-action-msgs - robostack-staging::ros-humble-action-msgs
- robostack-staging::ros-humble-ros-workspace - robostack-staging::ros-humble-ros-workspace
- robostack-staging::ros-humble-rosidl-default-runtime - robostack-staging::ros-humble-rosidl-default-runtime
- robostack-staging::ros-humble-std-msgs - robostack-staging::ros-humble-std-msgs
- robostack-staging::ros-humble-geometry-msgs - robostack-staging::ros-humble-geometry-msgs
- robostack-staging::ros2-distro-mutex=0.6.* # - robostack-staging::ros2-distro-mutex=0.6.*
- sel(osx and x86_64): __osx >={{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }} - sel(osx and x86_64): __osx >={{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.9.5" version: "0.9.6"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.9.5', version='0.9.6',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -2,4 +2,10 @@
```bash ```bash
ros2 action send_goal /devices/host_node/create_resource_detailed unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }" ros2 action send_goal /devices/host_node/create_resource_detailed unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }"
```
使用mock_all.json启动重新捕获MockContainerForChiller1
```bash
ros2 action send_goal /devices/host_node/create_resource unilabos_msgs/action/_resource_create_from_outer_easy/ResourceCreateFromOuterEasy "{ 'res_id': 'MockContainerForChiller1', 'device_id': 'MockChiller1', 'class_name': 'container', 'parent': 'MockChiller1', 'bind_locations': { 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'liquid_input_slot': [ -1 ], 'liquid_type': [ 'CuCl2' ], 'liquid_volume': [ 100.0 ], 'slot_on_deck': '' }"
``` ```

View File

@@ -77,8 +77,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"positions": 8, "positions": 8
"current_position": 1
}, },
"data": { "data": {
"valve_state": "Ready", "valve_state": "Ready",
@@ -98,8 +97,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"positions": 8, "positions": 8
"current_position": 1
}, },
"data": { "data": {
"valve_state": "Ready", "valve_state": "Ready",
@@ -489,19 +487,16 @@
"children": [], "children": [],
"parent": "ComprehensiveProtocolStation", "parent": "ComprehensiveProtocolStation",
"type": "device", "type": "device",
"class": "gas_source.mock", "class": "virtual_gas_source",
"position": { "position": {
"x": 650, "x": 650,
"y": 150, "y": 150,
"z": 0 "z": 0
}, },
"config": { "config": {},
"data": {
"gas_type": "nitrogen", "gas_type": "nitrogen",
"max_pressure": 5.0 "max_pressure": 5.0
},
"data": {
"status": "Off",
"current_pressure": 0.0
} }
}, },
{ {

View File

@@ -3,7 +3,9 @@
{ {
"id": "MockChiller1", "id": "MockChiller1",
"name": "模拟冷却器", "name": "模拟冷却器",
"children": [], "children": [
"MockContainerForChiller1"
],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_chiller", "class": "mock_chiller",
@@ -25,6 +27,22 @@
"purpose": "" "purpose": ""
} }
}, },
{
"id": "MockContainerForChiller1",
"name": "模拟容器",
"type": "container",
"parent": "MockChiller1",
"position": {
"x": 5,
"y": 0,
"z": 0
},
"data": {
"liquid_type": "CuCl2",
"liquid_volume": "100"
},
"children": []
},
{ {
"id": "MockFilter1", "id": "MockFilter1",
"name": "模拟过滤器", "name": "模拟过滤器",

View File

@@ -30,14 +30,17 @@
"children": [], "children": [],
"parent": "ReactorX", "parent": "ReactorX",
"type": "container", "type": "container",
"class": null, "class": "container",
"position": { "position": {
"x": 698.1111111111111, "x": 698.1111111111111,
"y": 428, "y": 428,
"z": 0 "z": 0
}, },
"config": { "config": {
"max_volume": 5000.0 "max_volume": 5000.0,
"size_x": 200.0,
"size_y": 200.0,
"size_z": 200.0
}, },
"data": { "data": {
"liquid": [ "liquid": [
@@ -71,7 +74,7 @@
"type": "device", "type": "device",
"class": "solenoid_valve.mock", "class": "solenoid_valve.mock",
"position": { "position": {
"x": 620.6111111111111, "x": 780,
"y": 171, "y": 171,
"z": 0 "z": 0
}, },
@@ -89,7 +92,7 @@
"type": "device", "type": "device",
"class": "vacuum_pump.mock", "class": "vacuum_pump.mock",
"position": { "position": {
"x": 620.6111111111111, "x": 500,
"y": 171, "y": 171,
"z": 0 "z": 0
}, },
@@ -107,7 +110,7 @@
"type": "device", "type": "device",
"class": "gas_source.mock", "class": "gas_source.mock",
"position": { "position": {
"x": 620.6111111111111, "x": 900,
"y": 171, "y": 171,
"z": 0 "z": 0
}, },

View File

@@ -8,6 +8,7 @@ def start_backend(
backend: str, backend: str,
devices_config: dict = {}, devices_config: dict = {},
resources_config: list = [], resources_config: list = [],
resources_edge_config: list = [],
graph=None, graph=None,
controllers_config: dict = {}, controllers_config: dict = {},
bridges=[], bridges=[],
@@ -31,7 +32,7 @@ def start_backend(
backend_thread = threading.Thread( backend_thread = threading.Thread(
target=main if not without_host else slave, target=main if not without_host else slave,
args=(devices_config, resources_config, graph, controllers_config, bridges, visual, resources_mesh_config), args=(devices_config, resources_config, resources_edge_config, graph, controllers_config, bridges, visual, resources_mesh_config),
name="backend_thread", name="backend_thread",
daemon=True, daemon=True,
) )

View File

@@ -10,7 +10,7 @@ from copy import deepcopy
import yaml import yaml
from unilabos.resources.graphio import tree_to_list from unilabos.resources.graphio import tree_to_list, modify_to_backend_format
# 首先添加项目根目录到路径 # 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -136,15 +136,16 @@ def main():
# 注册表 # 注册表
build_registry(args_dict["registry_path"]) build_registry(args_dict["registry_path"])
resource_edge_info = []
devices_and_resources = None devices_and_resources = None
if args_dict["graph"] is not None: if args_dict["graph"] is not None:
import unilabos.resources.graphio as graph_res import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = ( if args_dict["graph"].endswith(".json"):
read_node_link_json(args_dict["graph"]) graph, data = read_node_link_json(args_dict["graph"])
if args_dict["graph"].endswith(".json") else:
else read_graphml(args_dict["graph"]) graph, data = read_graphml(args_dict["graph"])
) graph_res.physical_setup_graph = graph
resource_edge_info = modify_to_backend_format(data["links"])
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph) devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
# args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values())) # args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
args_dict["resources_config"] = list(devices_and_resources.values()) args_dict["resources_config"] = list(devices_and_resources.values())
@@ -185,6 +186,7 @@ def main():
signal.signal(signal.SIGTERM, _exit) signal.signal(signal.SIGTERM, _exit)
mqtt_client.start() mqtt_client.start()
args_dict["resources_mesh_config"] = {} args_dict["resources_mesh_config"] = {}
args_dict["resources_edge_config"] = resource_edge_info
# web visiualize 2D # web visiualize 2D
if args_dict["visual"] != "disable": if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz" enable_rviz = args_dict["visual"] == "rviz"

View File

@@ -30,7 +30,27 @@ class HTTPClient:
self.auth = MQConfig.lab_id self.auth = MQConfig.lab_id
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_add(self, resources: List[Dict[str, Any]], database_process_later:bool) -> requests.Response: def resource_edge_add(self, resources: List[Dict[str, Any]], database_process_later: bool) -> requests.Response:
"""
添加资源
Args:
resources: 要添加的资源列表
database_process_later: 后台处理资源
Returns:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/resource/edge/batch_create/?database_process_later={1 if database_process_later else 0}",
json=resources,
headers={"Authorization": f"lab {self.auth}"},
timeout=5,
)
if response.status_code != 200:
logger.error(f"添加物料关系失败: {response.text}")
return response
def resource_add(self, resources: List[Dict[str, Any]], database_process_later: bool) -> requests.Response:
""" """
添加资源 添加资源
@@ -46,6 +66,8 @@ class HTTPClient:
headers={"Authorization": f"lab {self.auth}"}, headers={"Authorization": f"lab {self.auth}"},
timeout=5, timeout=5,
) )
if response.status_code != 200:
logger.error(f"添加物料失败: {response.text}")
return response return response
def resource_get(self, id: str, with_children: bool = False) -> Dict[str, Any]: def resource_get(self, id: str, with_children: bool = False) -> Dict[str, Any]:

View File

@@ -16,7 +16,6 @@ from jinja2 import Environment, FileSystemLoader
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.app.mq import mqtt_client
from unilabos.ros.msgs.message_converter import msg_converter_manager from unilabos.ros.msgs.message_converter import msg_converter_manager
from unilabos.utils.log import error from unilabos.utils.log import error
from unilabos.utils.type_check import TypeEncoder from unilabos.utils.type_check import TypeEncoder

View File

@@ -0,0 +1,46 @@
import time
from typing import Dict, Any, Optional
class VirtualGasSource:
"""Virtual gas source for testing"""
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
self.device_id = device_id or "unknown_gas_source"
self.config = config or {}
self.data = {}
self._status = "OPEN"
async def initialize(self) -> bool:
"""Initialize virtual gas source"""
self.data.update({
"status": self._status
})
return True
async def cleanup(self) -> bool:
"""Cleanup virtual gas source"""
return True
@property
def status(self) -> str:
return self._status
def get_status(self) -> str:
return self._status
def set_status(self, string):
self._status = string
time.sleep(5)
def open(self):
self._status = "OPEN"
def close(self):
self._status = "CLOSED"
def is_open(self):
return self._status
def is_closed(self):
return not self._status

View File

@@ -0,0 +1,172 @@
import asyncio
import logging
from typing import Dict, Any, Optional
class VirtualRotavap:
"""Virtual rotary evaporator device for EvaporateProtocol testing"""
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and "id" in kwargs:
device_id = kwargs.pop("id")
if config is None and "config" in kwargs:
config = kwargs.pop("config")
# 设置默认值
self.device_id = device_id or "unknown_rotavap"
self.config = config or {}
self.logger = logging.getLogger(f"VirtualRotavap.{self.device_id}")
self.data = {}
# 添加调试信息
print(f"=== VirtualRotavap {self.device_id} is being created! ===")
print(f"=== Config: {self.config} ===")
print(f"=== Kwargs: {kwargs} ===")
# 从config或kwargs中获取配置参数
self.port = self.config.get("port") or kwargs.get("port", "VIRTUAL")
self._max_temp = self.config.get("max_temp") or kwargs.get("max_temp", 180.0)
self._max_rotation_speed = self.config.get("max_rotation_speed") or kwargs.get("max_rotation_speed", 280.0)
# 处理其他kwargs参数但跳过已知的配置参数
skip_keys = {"port", "max_temp", "max_rotation_speed"}
for key, value in kwargs.items():
if key not in skip_keys and not hasattr(self, key):
setattr(self, key, value)
async def initialize(self) -> bool:
"""Initialize virtual rotary evaporator"""
print(f"=== VirtualRotavap {self.device_id} initialize() called! ===")
self.logger.info(f"Initializing virtual rotary evaporator {self.device_id}")
self.data.update(
{
"status": "Idle",
"rotavap_state": "Ready",
"current_temp": 25.0,
"target_temp": 25.0,
"max_temp": self._max_temp,
"rotation_speed": 0.0,
"max_rotation_speed": self._max_rotation_speed,
"vacuum_pressure": 1.0, # atmospheric pressure
"evaporated_volume": 0.0,
"progress": 0.0,
"message": "",
}
)
return True
async def cleanup(self) -> bool:
"""Cleanup virtual rotary evaporator"""
self.logger.info(f"Cleaning up virtual rotary evaporator {self.device_id}")
return True
async def evaporate(
self, vessel: str, pressure: float = 0.5, temp: float = 60.0, time: float = 300.0, stir_speed: float = 100.0
) -> bool:
"""Execute evaporate action - matches Evaporate action"""
self.logger.info(f"Evaporate: vessel={vessel}, pressure={pressure}, temp={temp}, time={time}")
# 验证参数
if temp > self._max_temp:
self.logger.error(f"Temperature {temp} exceeds maximum {self._max_temp}")
self.data["message"] = f"温度 {temp} 超过最大值 {self._max_temp}"
return False
if stir_speed > self._max_rotation_speed:
self.logger.error(f"Rotation speed {stir_speed} exceeds maximum {self._max_rotation_speed}")
self.data["message"] = f"旋转速度 {stir_speed} 超过最大值 {self._max_rotation_speed}"
return False
if pressure < 0.01 or pressure > 1.0:
self.logger.error(f"Pressure {pressure} bar is out of valid range (0.01-1.0)")
self.data["message"] = f"真空度 {pressure} bar 超出有效范围 (0.01-1.0)"
return False
# 开始蒸发
self.data.update(
{
"status": "Running",
"rotavap_state": "Evaporating",
"target_temp": temp,
"current_temp": temp,
"rotation_speed": stir_speed,
"vacuum_pressure": pressure,
"vessel": vessel,
"target_time": time,
"progress": 0.0,
"message": f"正在蒸发: {vessel}",
}
)
# 模拟蒸发过程
simulation_time = min(time / 60.0, 10.0) # 最多模拟10秒
for progress in range(0, 101, 10):
await asyncio.sleep(simulation_time / 10)
self.data["progress"] = progress
self.data["evaporated_volume"] = progress * 0.5 # 假设最多蒸发50mL
# 蒸发完成
evaporated_vol = 50.0 # 假设蒸发了50mL
self.data.update(
{
"status": "Idle",
"rotavap_state": "Ready",
"current_temp": 25.0,
"target_temp": 25.0,
"rotation_speed": 0.0,
"vacuum_pressure": 1.0,
"evaporated_volume": evaporated_vol,
"progress": 100.0,
"message": f"蒸发完成: {evaporated_vol}mL",
}
)
self.logger.info(f"Evaporation completed: {evaporated_vol}mL from {vessel}")
return True
# 状态属性
@property
def status(self) -> str:
return self.data.get("status", "Unknown")
@property
def rotavap_state(self) -> str:
return self.data.get("rotavap_state", "Unknown")
@property
def current_temp(self) -> float:
return self.data.get("current_temp", 25.0)
@property
def target_temp(self) -> float:
return self.data.get("target_temp", 25.0)
@property
def max_temp(self) -> float:
return self.data.get("max_temp", self._max_temp)
@property
def rotation_speed(self) -> float:
return self.data.get("rotation_speed", 0.0)
@property
def max_rotation_speed(self) -> float:
return self.data.get("max_rotation_speed", self._max_rotation_speed)
@property
def vacuum_pressure(self) -> float:
return self.data.get("vacuum_pressure", 1.0)
@property
def evaporated_volume(self) -> float:
return self.data.get("evaporated_volume", 0.0)
@property
def progress(self) -> float:
return self.data.get("progress", 0.0)
@property
def message(self) -> str:
return self.data.get("message", "")

View File

@@ -0,0 +1,184 @@
import asyncio
import logging
from typing import Dict, Any, Optional
class VirtualSeparator:
"""Virtual separator device for SeparateProtocol testing"""
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and "id" in kwargs:
device_id = kwargs.pop("id")
if config is None and "config" in kwargs:
config = kwargs.pop("config")
# 设置默认值
self.device_id = device_id or "unknown_separator"
self.config = config or {}
self.logger = logging.getLogger(f"VirtualSeparator.{self.device_id}")
self.data = {}
# 添加调试信息
print(f"=== VirtualSeparator {self.device_id} is being created! ===")
print(f"=== Config: {self.config} ===")
print(f"=== Kwargs: {kwargs} ===")
# 从config或kwargs中获取配置参数
self.port = self.config.get("port") or kwargs.get("port", "VIRTUAL")
self._volume = self.config.get("volume") or kwargs.get("volume", 250.0)
self._has_phases = self.config.get("has_phases") or kwargs.get("has_phases", True)
# 处理其他kwargs参数但跳过已知的配置参数
skip_keys = {"port", "volume", "has_phases"}
for key, value in kwargs.items():
if key not in skip_keys and not hasattr(self, key):
setattr(self, key, value)
async def initialize(self) -> bool:
"""Initialize virtual separator"""
print(f"=== VirtualSeparator {self.device_id} initialize() called! ===")
self.logger.info(f"Initializing virtual separator {self.device_id}")
self.data.update(
{
"status": "Ready",
"separator_state": "Ready",
"volume": self._volume,
"has_phases": self._has_phases,
"phase_separation": False,
"stir_speed": 0.0,
"settling_time": 0.0,
"progress": 0.0,
"message": "",
}
)
return True
async def cleanup(self) -> bool:
"""Cleanup virtual separator"""
self.logger.info(f"Cleaning up virtual separator {self.device_id}")
return True
async def separate(
self,
purpose: str,
product_phase: str,
from_vessel: str,
separation_vessel: str,
to_vessel: str,
waste_phase_to_vessel: str = "",
solvent: str = "",
solvent_volume: float = 50.0,
through: str = "",
repeats: int = 1,
stir_time: float = 30.0,
stir_speed: float = 300.0,
settling_time: float = 300.0,
) -> bool:
"""Execute separate action - matches Separate action"""
self.logger.info(f"Separate: purpose={purpose}, product_phase={product_phase}, from_vessel={from_vessel}")
# 验证参数
if product_phase not in ["top", "bottom"]:
self.logger.error(f"Invalid product_phase {product_phase}, must be 'top' or 'bottom'")
self.data["message"] = f"产物相位 {product_phase} 无效,必须是 'top''bottom'"
return False
if purpose not in ["wash", "extract"]:
self.logger.error(f"Invalid purpose {purpose}, must be 'wash' or 'extract'")
self.data["message"] = f"分离目的 {purpose} 无效,必须是 'wash''extract'"
return False
# 开始分离
self.data.update(
{
"status": "Running",
"separator_state": "Separating",
"purpose": purpose,
"product_phase": product_phase,
"from_vessel": from_vessel,
"separation_vessel": separation_vessel,
"to_vessel": to_vessel,
"waste_phase_to_vessel": waste_phase_to_vessel,
"solvent": solvent,
"solvent_volume": solvent_volume,
"repeats": repeats,
"stir_speed": stir_speed,
"settling_time": settling_time,
"phase_separation": True,
"progress": 0.0,
"message": f"正在分离: {from_vessel} -> {to_vessel}",
}
)
# 模拟分离过程
total_time = (stir_time + settling_time) * repeats
simulation_time = min(total_time / 60.0, 15.0) # 最多模拟15秒
for repeat in range(repeats):
# 搅拌阶段
for progress in range(0, 51, 10):
await asyncio.sleep(simulation_time / (repeats * 10))
overall_progress = ((repeat * 100) + (progress * 0.5)) / repeats
self.data["progress"] = overall_progress
self.data["message"] = f"{repeat+1}次分离 - 搅拌中 ({progress}%)"
# 静置分相阶段
for progress in range(50, 101, 10):
await asyncio.sleep(simulation_time / (repeats * 10))
overall_progress = ((repeat * 100) + (progress * 0.5)) / repeats
self.data["progress"] = overall_progress
self.data["message"] = f"{repeat+1}次分离 - 静置分相中 ({progress}%)"
# 分离完成
self.data.update(
{
"status": "Ready",
"separator_state": "Ready",
"phase_separation": False,
"stir_speed": 0.0,
"progress": 100.0,
"message": f"分离完成: {repeats}次分离操作",
}
)
self.logger.info(f"Separation completed: {repeats} cycles from {from_vessel} to {to_vessel}")
return True
# 状态属性
@property
def status(self) -> str:
return self.data.get("status", "Unknown")
@property
def separator_state(self) -> str:
return self.data.get("separator_state", "Unknown")
@property
def volume(self) -> float:
return self.data.get("volume", self._volume)
@property
def has_phases(self) -> bool:
return self.data.get("has_phases", self._has_phases)
@property
def phase_separation(self) -> bool:
return self.data.get("phase_separation", False)
@property
def stir_speed(self) -> float:
return self.data.get("stir_speed", 0.0)
@property
def settling_time(self) -> float:
return self.data.get("settling_time", 0.0)
@property
def progress(self) -> float:
return self.data.get("progress", 0.0)
@property
def message(self) -> str:
return self.data.get("message", "")

View File

@@ -14,9 +14,10 @@ class VirtualPumpMode(Enum):
class VirtualPump: class VirtualPump:
"""虚拟泵类 - 模拟泵的基本功能,无需实际硬件""" """虚拟泵类 - 模拟泵的基本功能,无需实际硬件"""
def __init__(self, device_id: str = None, max_volume: float = 25.0, mode: VirtualPumpMode = VirtualPumpMode.Normal): def __init__(self, device_id: str = None, max_volume: float = 25.0, mode: VirtualPumpMode = VirtualPumpMode.Normal, transfer_rate=0):
self.device_id = device_id or "virtual_pump" self.device_id = device_id or "virtual_pump"
self.max_volume = max_volume self.max_volume = max_volume
self._transfer_rate = transfer_rate
self.mode = mode self.mode = mode
# 状态变量 # 状态变量
@@ -24,7 +25,7 @@ class VirtualPump:
self._position = 0.0 # 当前柱塞位置 (ml) self._position = 0.0 # 当前柱塞位置 (ml)
self._max_velocity = 5.0 # 默认最大速度 (ml/s) self._max_velocity = 5.0 # 默认最大速度 (ml/s)
self._current_volume = 0.0 # 当前注射器中的体积 self._current_volume = 0.0 # 当前注射器中的体积
self.logger = logging.getLogger(f"VirtualPump.{self.device_id}") self.logger = logging.getLogger(f"VirtualPump.{self.device_id}")
async def initialize(self) -> bool: async def initialize(self) -> bool:
@@ -60,6 +61,10 @@ class VirtualPump:
def max_velocity(self) -> float: def max_velocity(self) -> float:
return self._max_velocity return self._max_velocity
@property
def transfer_rate(self) -> float:
return self._transfer_rate
def set_max_velocity(self, velocity: float): def set_max_velocity(self, velocity: float):
"""设置最大速度 (ml/s)""" """设置最大速度 (ml/s)"""
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内 self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内

View File

@@ -0,0 +1,47 @@
import asyncio
import time
from typing import Dict, Any, Optional
class VirtualVacuumPump:
"""Virtual vacuum pump for testing"""
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
self.device_id = device_id or "unknown_vacuum_pump"
self.config = config or {}
self.data = {}
self._status = "OPEN"
async def initialize(self) -> bool:
"""Initialize virtual vacuum pump"""
self.data.update({
"status": self._status
})
return True
async def cleanup(self) -> bool:
"""Cleanup virtual vacuum pump"""
return True
@property
def status(self) -> str:
return self._status
def get_status(self) -> str:
return self._status
def set_status(self, string):
self._status = string
time.sleep(5)
def open(self):
self._status = "OPEN"
def close(self):
self._status = "CLOSED"
def is_open(self):
return self._status
def is_closed(self):
return not self._status

View File

@@ -48,14 +48,14 @@ solenoid_valve.mock:
feedback: {} feedback: {}
result: {} result: {}
handles: handles:
input: - handler_key: 0
- handler_key: fluid-input label: 0
label: Fluid Input
data_type: fluid data_type: fluid
output: side: NORTH
- handler_key: fluid-output - handler_key: 1
label: Fluid Output label: 1
data_type: fluid data_type: fluid
side: SOUTH
init_param_schema: init_param_schema:
type: object type: object
properties: properties:

View File

@@ -23,20 +23,12 @@ vacuum_pump.mock:
feedback: {} feedback: {}
result: {} result: {}
handles: handles:
input: - handler_key: out
- handler_key: fluid-input label: out
label: Fluid Input
data_type: fluid data_type: fluid
io_type: target io_type: target
data_source: handle data_source: handle
data_key: fluid_in data_key: fluid_in
output:
- handler_key: fluid-output
label: Fluid Output
data_type: fluid
io_type: source
data_source: executor
data_key: fluid_out
init_param_schema: init_param_schema:
type: object type: object
properties: properties:
@@ -72,16 +64,8 @@ gas_source.mock:
feedback: {} feedback: {}
result: {} result: {}
handles: handles:
input: - handler_key: out
- handler_key: fluid-input label: out
label: Fluid Input
data_type: fluid
io_type: target
data_source: handle
data_key: fluid_in
output:
- handler_key: fluid-output
label: Fluid Output
data_type: fluid data_type: fluid
io_type: source io_type: source
data_source: executor data_source: executor

View File

@@ -7,7 +7,7 @@
# 2. virtual_stirrer - 虚拟搅拌器 # 2. virtual_stirrer - 虚拟搅拌器
# 描述:机械连接设备,提供搅拌功能 # 描述:机械连接设备,提供搅拌功能
# 连接特性1个双向连接点bidirectional # 连接特性1个双向连接点undirected
# 数据类型mechanical机械连接 # 数据类型mechanical机械连接
# 3a. virtual_valve - 虚拟八通阀门 # 3a. virtual_valve - 虚拟八通阀门
@@ -32,18 +32,29 @@
# 6. virtual_heatchill - 虚拟加热/冷却器 # 6. virtual_heatchill - 虚拟加热/冷却器
# 描述:温控设备,容器直接放置在设备上进行温度控制 # 描述:温控设备,容器直接放置在设备上进行温度控制
# 连接特性1个双向连接点bidirectional # 连接特性1个双向连接点undirected
# 数据类型mechanical机械/物理接触连接) # 数据类型mechanical机械/物理接触连接)
# 7. virtual_transfer_pump - 虚拟转移泵(注射器式) # 7. virtual_transfer_pump - 虚拟转移泵(注射器式)
# 描述:注射器式转移泵,通过同一个口吸入和排出液体 # 描述:注射器式转移泵,通过同一个口吸入和排出液体
# 连接特性1个双向连接点bidirectional # 连接特性1个双向连接点undirected
# 数据类型fluid流体连接 # 数据类型fluid流体连接
# 8. virtual_column - 虚拟色谱柱 # 8. virtual_column - 虚拟色谱柱
# 描述:分离纯化设备,用于样品纯化 # 描述:分离纯化设备,用于样品纯化
# 连接特性1个输入口 + 1个输出口 # 连接特性1个输入口 + 1个输出口
# 数据类型resource资源/样品连接) # 数据类型resource资源/样品连接)
# 9. virtual_rotavap - 虚拟旋转蒸发仪
# 描述:旋转蒸发仪用于溶剂蒸发和浓缩,具有加热、旋转和真空功能
# 连接特性1个输入口样品1个输出口浓缩物1个冷凝器出口回收溶剂
# 数据类型resource资源/样品连接)
# 10. virtual_separator - 虚拟分液器
# 描述:分液器用于两相液体的分离,可进行萃取和洗涤操作
# 连接特性1个输入口混合液2个输出口上相和下相
# 数据类型fluid流体连接
virtual_pump: virtual_pump:
description: Virtual Pump for PumpTransferProtocol Testing description: Virtual Pump for PumpTransferProtocol Testing
class: class:
@@ -52,7 +63,7 @@ virtual_pump:
status_types: status_types:
status: String status: String
position: Float64 position: Float64
valve_position: Int32 # 修复:使用 Int32 而不是 String valve_position: Int32 # 修复:使用 Int32 而不是 String
max_volume: Float64 max_volume: Float64
current_volume: Float64 current_volume: Float64
action_value_mappings: action_value_mappings:
@@ -83,22 +94,13 @@ virtual_pump:
success: success success: success
# 虚拟泵节点配置 - 具有多通道阀门特性根据valve_position可连接多个容器 # 虚拟泵节点配置 - 具有多通道阀门特性根据valve_position可连接多个容器
handles: handles:
input: - handler_key: pump-inlet
- handler_key: pump-inlet label: Pump Inlet
label: Pump Inlet data_type: fluid
data_type: fluid io_type: target
io_type: target data_source: handle
data_source: handle data_key: fluid_in
data_key: fluid_in description: "泵的进液口,连接源容器"
description: "泵的进液口,连接源容器"
output:
- handler_key: pump-outlet-1
label: Outlet Port 1
data_type: fluid
io_type: source
data_source: executor
data_key: fluid_out_1
description: "阀门位置1的出液口"
schema: schema:
type: object type: object
properties: properties:
@@ -148,14 +150,14 @@ virtual_stirrer:
success: success success: success
# 虚拟搅拌器节点配置 - 机械连接设备,单一双向连接点 # 虚拟搅拌器节点配置 - 机械连接设备,单一双向连接点
handles: handles:
bidirectional: - handler_key: stirrer-vessel
- handler_key: stirrer-vessel label: Vessel Connection
label: Vessel Connection data_type: mechanical
data_type: mechanical side: SOUTH
io_type: bidirectional io_type: undirected
data_source: handle data_source: handle
data_key: vessel data_key: vessel
description: "搅拌器的机械连接口,直接与反应容器连接提供搅拌功能" description: "搅拌器的机械连接口,直接与反应容器连接提供搅拌功能"
schema: schema:
type: object type: object
properties: properties:
@@ -191,71 +193,77 @@ virtual_multiway_valve:
success: success success: success
# 八通阀门节点配置 - 1个输入口8个输出口可切换流向 # 八通阀门节点配置 - 1个输入口8个输出口可切换流向
handles: handles:
input: - handler_key: multiway-valve-inlet
- handler_key: multiway-valve-inlet label: Valve Inlet
label: Valve Inlet data_type: fluid
data_type: fluid io_type: target
io_type: target data_source: handle
data_source: handle data_key: fluid_in
data_key: fluid_in description: "八通阀门进液口,接收来源流体"
description: "八通阀门进液口,接收来源流体" - handler_key: multiway-valve-port-1
output: label: 1
- handler_key: multiway-valve-port-1 data_type: fluid
label: Port 1 side: NORTH
data_type: fluid io_type: source
io_type: source data_source: executor
data_source: executor data_key: fluid_port_1
data_key: fluid_port_1 description: "八通阀门端口1position=1时流体从此口流出"
description: "八通阀门端口1position=1时流体从此口流出" - handler_key: multiway-valve-port-2
- handler_key: multiway-valve-port-2 label: 2
label: Port 2 data_type: fluid
data_type: fluid side: EAST
io_type: source io_type: source
data_source: executor data_source: executor
data_key: fluid_port_2 data_key: fluid_port_2
description: "八通阀门端口2position=2时流体从此口流出" description: "八通阀门端口2position=2时流体从此口流出"
- handler_key: multiway-valve-port-3 - handler_key: multiway-valve-port-3
label: Port 3 label: 3
data_type: fluid data_type: fluid
io_type: source side: EAST
data_source: executor io_type: source
data_key: fluid_port_3 data_source: executor
description: "八通阀门端口3position=3时流体从此口流出" data_key: fluid_port_3
- handler_key: multiway-valve-port-4 description: "八通阀门端口3position=3时流体从此口流出"
label: Port 4 - handler_key: multiway-valve-port-4
data_type: fluid label: 4
io_type: source data_type: fluid
data_source: executor side: SOUTH
data_key: fluid_port_4 io_type: source
description: "八通阀门端口4position=4时流体从此口流出" data_source: executor
- handler_key: multiway-valve-port-5 data_key: fluid_port_4
label: Port 5 description: "八通阀门端口4position=4时流体从此口流出"
data_type: fluid - handler_key: multiway-valve-port-5
io_type: source label: 5
data_source: executor data_type: fluid
data_key: fluid_port_5 side: SOUTH
description: "八通阀门端口5position=5时流体从此口流出" io_type: source
- handler_key: multiway-valve-port-6 data_source: executor
label: Port 6 data_key: fluid_port_5
data_type: fluid description: "八通阀门端口5position=5时流体从此口流出"
io_type: source - handler_key: multiway-valve-port-7
data_source: executor label: 7
data_key: fluid_port_6 data_type: fluid
description: "八通阀门端口6position=6时流体从此口流出" side: WEST
- handler_key: multiway-valve-port-7 io_type: source
label: Port 7 data_source: executor
data_type: fluid data_key: fluid_port_7
io_type: source description: "八通阀门端口7position=7时流体从此口流出"
data_source: executor - handler_key: multiway-valve-port-6
data_key: fluid_port_7 label: 6
description: "八通阀门端口7position=7时流体从此口流出" data_type: fluid
- handler_key: multiway-valve-port-8 side: WEST
label: Port 8 io_type: source
data_type: fluid data_source: executor
io_type: source data_key: fluid_port_6
data_source: executor description: "八通阀门端口6position=6时流体从此口流出"
data_key: fluid_port_8 - handler_key: multiway-valve-port-8
description: "八通阀门端口8position=8时流体从此口流出" label: 8
data_type: fluid
side: NORTH
io_type: source
data_source: executor
data_key: fluid_port_8
description: "八通阀门端口8position=8时流体从此口流出"
schema: schema:
type: object type: object
properties: properties:
@@ -273,19 +281,19 @@ virtual_solenoid_valve:
type: python type: python
status_types: status_types:
status: String status: String
valve_state: String # "open" or "closed" valve_state: String # "open" or "closed"
is_open: Bool is_open: Bool
action_value_mappings: action_value_mappings:
open: open:
type: SendCmd type: SendCmd
goal: goal:
command: "open" command: "open"
feedback: {} feedback: {}
result: result:
success: success success: success
close: close:
type: SendCmd type: SendCmd
goal: goal:
command: "close" command: "close"
feedback: {} feedback: {}
result: result:
@@ -293,20 +301,26 @@ virtual_solenoid_valve:
set_state: set_state:
type: SendCmd type: SendCmd
goal: goal:
command: command command: command
feedback: {} feedback: {}
result: result:
success: success success: success
# 电磁阀门节点配置 - 双向流通的开关型阀门,流动方向由泵决定 # 电磁阀门节点配置 - 双向流通的开关型阀门,流动方向由泵决定
handles: handles:
bidirectional: - handler_key: solenoid-valve-port-in
- handler_key: solenoid-valve-port label: in
label: Valve Port data_type: fluid
data_type: fluid io_type: undirected
io_type: bidirectional data_source: handle
data_source: handle data_key: fluid_port
data_key: fluid_port description: "电磁阀的双向流体口,开启时允许流体双向通过,关闭时完全阻断"
description: "电磁阀的双向流体口,开启时允许流体双向通过,关闭时完全阻断" - handler_key: solenoid-valve-port-out
label: out
data_type: fluid
io_type: undirected
data_source: handle
data_key: fluid_port
description: "电磁阀的双向流体口,开启时允许流体双向通过,关闭时完全阻断"
schema: schema:
type: object type: object
properties: properties:
@@ -354,22 +368,13 @@ virtual_centrifuge:
message: message message: message
# 虚拟离心机节点配置 - 单个样品处理设备,输入输出都是同一个样品容器 # 虚拟离心机节点配置 - 单个样品处理设备,输入输出都是同一个样品容器
handles: handles:
input: - handler_key: centrifuge-sample
- handler_key: centrifuge-sample label: Sample Input/Output
label: Sample Input data_type: transport
data_type: resource io_type: undirected
io_type: target data_source: handle
data_source: handle data_key: vessel
data_key: vessel description: "需要离心的样品容器"
description: "需要离心的样品容器"
output:
- handler_key: centrifuge-sample-out
label: Centrifuged Sample
data_type: resource
io_type: source
data_source: executor
data_key: vessel
description: "经过离心处理的样品容器"
schema: schema:
type: object type: object
properties: properties:
@@ -424,29 +429,30 @@ virtual_filter:
message: message message: message
# 虚拟过滤器节点配置 - 分离设备1个输入(原始样品)2个输出(滤液和滤渣) # 虚拟过滤器节点配置 - 分离设备1个输入(原始样品)2个输出(滤液和滤渣)
handles: handles:
input: - handler_key: filter-in
- handler_key: filter-sample-in label: Input
label: Sample Input data_type: fluid
data_type: resource side: NORTH
io_type: target io_type: target
data_source: handle data_source: handle
data_key: vessel data_key: vessel
description: "需要过滤的原始样品容器" description: "需要过滤的原始样品容器"
output: - handler_key: filter-filtrate-out
- handler_key: filter-filtrate-out label: Output
label: Filtrate Output data_type: fluid
data_type: resource side: SOUTH
io_type: source io_type: source
data_source: executor data_source: executor
data_key: filtrate_vessel data_key: filtrate_vessel
description: "过滤后的滤液容器" description: "过滤后的滤液容器"
- handler_key: filter-residue-out - handler_key: filter-residue-out
label: Filter Residue label: Residue
data_type: resource data_type: resource
io_type: source side: WEST
data_source: executor io_type: source
data_key: residue_vessel data_source: executor
description: "过滤后的滤渣(固体残留物)" data_key: residue_vessel
description: "过滤后的滤渣(固体残留物)"
schema: schema:
type: object type: object
properties: properties:
@@ -502,14 +508,14 @@ virtual_heatchill:
success: success success: success
# 虚拟加热/冷却器节点配置 - 温控设备,单一双向连接点用于放置容器 # 虚拟加热/冷却器节点配置 - 温控设备,单一双向连接点用于放置容器
handles: handles:
bidirectional: - handler_key: heatchill-vessel
- handler_key: heatchill-vessel label: Connection
label: Vessel Connection data_type: mechanical
data_type: mechanical side: NORTH
io_type: bidirectional io_type: undirected
data_source: handle data_source: handle
data_key: vessel data_key: vessel
description: "加热/冷却器的物理连接口,容器直接放置在设备上进行温度控制" description: "加热/冷却器的物理连接口,容器直接放置在设备上进行温度控制"
schema: schema:
type: object type: object
properties: properties:
@@ -521,7 +527,7 @@ virtual_heatchill:
default: 200.0 default: 200.0
min_temp: min_temp:
type: number type: number
default: -80.0 default: -80
max_stir_speed: max_stir_speed:
type: number type: number
default: 1000.0 default: 1000.0
@@ -530,18 +536,13 @@ virtual_heatchill:
virtual_transfer_pump: virtual_transfer_pump:
description: Virtual Transfer Pump for TransferProtocol Testing (Syringe-style) description: Virtual Transfer Pump for TransferProtocol Testing (Syringe-style)
class: class:
module: unilabos.devices.virtual.virtual_transferpump:VirtualTransferPump module: unilabos.devices.virtual.virtual_transferpump:VirtualPump
type: python type: python
status_types: status_types:
status: String status: String
current_volume: Float64 current_volume: Float64
max_volume: Float64 max_volume: Float64
transfer_rate: Float64 transfer_rate: Float64
from_vessel: String
to_vessel: String
progress: Float64
transferred_volume: Float64
current_status: String
action_value_mappings: action_value_mappings:
transfer: transfer:
type: Transfer type: Transfer
@@ -565,11 +566,11 @@ virtual_transfer_pump:
message: message message: message
# 注射器式转移泵节点配置 - 只有一个双向连接口,可吸入和排出液体 # 注射器式转移泵节点配置 - 只有一个双向连接口,可吸入和排出液体
handles: handles:
bidirectional: undirected:
- handler_key: syringe-port - handler_key: syringe-port
label: Syringe Port label: Syringe Port
data_type: fluid data_type: fluid
io_type: bidirectional io_type: undirected
data_source: handle data_source: handle
data_key: fluid_port data_key: fluid_port
description: "注射器式转移泵的唯一连接口,通过阀门切换实现吸入和排出" description: "注射器式转移泵的唯一连接口,通过阀门切换实现吸入和排出"
@@ -617,22 +618,22 @@ virtual_column:
message: message message: message
# 虚拟色谱柱节点配置 - 分离纯化设备1个样品输入口1个纯化产物输出口 # 虚拟色谱柱节点配置 - 分离纯化设备1个样品输入口1个纯化产物输出口
handles: handles:
input: - handler_key: column-sample-inlet
- handler_key: column-sample-inlet label: Sample Input
label: Sample Input data_type: fluid
data_type: resource side: NORTH
io_type: target io_type: target
data_source: handle data_source: handle
data_key: from_vessel data_key: from_vessel
description: "需要纯化的样品输入口" description: "需要纯化的样品输入口"
output: - handler_key: column-product-outlet
- handler_key: column-product-outlet label: Purified Product
label: Purified Product data_type: fluid
data_type: resource side: SOUTH
io_type: source io_type: source
data_source: executor data_source: executor
data_key: to_vessel data_key: to_vessel
description: "经过色谱柱纯化的产物输出口" description: "经过色谱柱纯化的产物输出口"
schema: schema:
type: object type: object
properties: properties:
@@ -648,4 +649,238 @@ virtual_column:
column_diameter: column_diameter:
type: number type: number
default: 2.0 default: 2.0
additionalProperties: false additionalProperties: false
virtual_rotavap:
description: Virtual Rotary Evaporator for EvaporateProtocol Testing
class:
module: unilabos.devices.virtual.virtual_rotavap:VirtualRotavap
type: python
status_types:
status: String
rotavap_state: String
current_temp: Float64
target_temp: Float64
max_temp: Float64
rotation_speed: Float64
max_rotation_speed: Float64
vacuum_pressure: Float64
evaporated_volume: Float64
progress: Float64
message: String
action_value_mappings:
evaporate:
type: Evaporate
goal:
vessel: vessel
pressure: pressure
temp: temp
time: time
stir_speed: stir_speed
feedback:
progress: progress
current_temp: current_temp
evaporated_volume: evaporated_volume
current_status: status
result:
success: success
message: message
# 虚拟旋转蒸发仪节点配置 - 蒸发浓缩设备1个输入口(样品)2个输出口(浓缩物和冷凝液)
handles:
- handler_key: rotavap-sample-inlet
label: Sample Input
data_type: fluid
side: NORTH
io_type: target
data_source: handle
data_key: vessel
description: "需要蒸发的样品输入口"
- handler_key: rotavap-concentrate-outlet
label: Concentrate
data_type: fluid
side: SOUTH
io_type: source
data_source: executor
data_key: concentrate_vessel
description: "蒸发浓缩后的产物输出口"
- handler_key: rotavap-distillate-outlet
label: Distillate
data_type: fluid
side: WEST
io_type: source
data_source: executor
data_key: distillate_vessel
description: "冷凝回收的溶剂输出口"
schema:
type: object
properties:
port:
type: string
default: "VIRTUAL"
max_temp:
type: number
default: 180.0
max_rotation_speed:
type: number
default: 280.0
additionalProperties: false
virtual_separator:
description: Virtual Separator for SeparateProtocol Testing
class:
module: unilabos.devices.virtual.virtual_separator:VirtualSeparator
type: python
status_types:
status: String
separator_state: String
volume: Float64
has_phases: Bool
phase_separation: Bool
stir_speed: Float64
settling_time: Float64
progress: Float64
message: String
action_value_mappings:
separate:
type: Separate
goal:
purpose: purpose
product_phase: product_phase
from_vessel: from_vessel
separation_vessel: separation_vessel
to_vessel: to_vessel
waste_phase_to_vessel: waste_phase_to_vessel
solvent: solvent
solvent_volume: solvent_volume
through: through
repeats: repeats
stir_time: stir_time
stir_speed: stir_speed
settling_time: settling_time
feedback:
progress: progress
current_status: status
result:
success: success
message: message
# 虚拟分液器节点配置 - 分离设备1个输入口(混合液)2个输出口(上相和下相)
handles:
- handler_key: separator-inlet
label: Mixed Input
data_type: fluid
side: NORTH
io_type: target
data_source: handle
data_key: from_vessel
description: "需要分离的混合液体输入口"
- handler_key: separator-top-outlet
label: Top Phase
data_type: fluid
side: EAST
io_type: source
data_source: executor
data_key: top_outlet
description: "上相(轻相)液体输出口"
- handler_key: separator-bottom-outlet
label: Bottom Phase
data_type: fluid
side: SOUTH
io_type: source
data_source: executor
data_key: bottom_outlet
description: "下相(重相)液体输出口"
schema:
type: object
properties:
port:
type: string
default: "VIRTUAL"
volume:
type: number
default: 250.0
has_phases:
type: boolean
default: true
additionalProperties: false
virtual_vacuum_pump:
description: Virtual vacuum pump
class:
module: unilabos.devices.virtual.virtual_vacuum_pump:VirtualVacuumPump
type: python
status_types:
status: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
set_status:
type: StrSingleInput
goal:
string: string
feedback: {}
result: {}
handles:
- handler_key: out
label: out
data_type: fluid
io_type: target
data_source: handle
data_key: fluid_in
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "VIRTUAL"
required:
- port
virtual_gas_source:
description: Virtual gas source
class:
module: unilabos.devices.virtual.virtual_gas_source:VirtualGasSource
type: python
status_types:
status: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
set_status:
type: StrSingleInput
goal:
string: string
feedback: {}
result: {}
handles:
- handler_key: out
label: out
data_type: fluid
io_type: source
data_source: executor
data_key: fluid_out
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "VIRTUAL"
required:
- port

View File

@@ -0,0 +1,14 @@
container:
description: regular organic container
class:
module: unilabos.resources.container:RegularContainer
type: unilabos
handles:
- handler_key: top
label: top
data_type: fluid
side: NORTH
- handler_key: bottom
label: bottom
data_type: fluid
side: SOUTH

View File

@@ -0,0 +1,67 @@
import json
from unilabos_msgs.msg import Resource
from unilabos.ros.msgs.message_converter import convert_from_ros_msg
class RegularContainer(object):
# 第一个参数必须是id传入
# noinspection PyShadowingBuiltins
def __init__(self, id: str):
self.id = id
self.ulr_resource = Resource()
self._data = None
@property
def ulr_resource_data(self):
if self._data is None:
self._data = json.loads(self.ulr_resource.data) if self.ulr_resource.data else {}
return self._data
@ulr_resource_data.setter
def ulr_resource_data(self, value: dict):
self._data = value
self.ulr_resource.data = json.dumps(self._data)
@property
def liquid_type(self):
return self.ulr_resource_data.get("liquid_type", None)
@liquid_type.setter
def liquid_type(self, value: str):
if value is not None:
self.ulr_resource_data["liquid_type"] = value
else:
self.ulr_resource_data.pop("liquid_type", None)
@property
def liquid_volume(self):
return self.ulr_resource_data.get("liquid_volume", None)
@liquid_volume.setter
def liquid_volume(self, value: float):
if value is not None:
self.ulr_resource_data["liquid_volume"] = value
else:
self.ulr_resource_data.pop("liquid_volume", None)
def get_ulr_resource(self) -> Resource:
"""
获取UlrResource对象
:return: UlrResource对象
"""
self.ulr_resource_data = self.ulr_resource_data # 确保数据被更新
return self.ulr_resource
def get_ulr_resource_as_dict(self) -> Resource:
"""
获取UlrResource对象
:return: UlrResource对象
"""
to_dict = convert_from_ros_msg(self.get_ulr_resource())
to_dict["type"] = "container"
return to_dict
def __str__(self):
return f"{self.id}"

View File

@@ -1,9 +1,13 @@
import importlib import importlib
import inspect import inspect
import json import json
from typing import Union from typing import Union, Any
import numpy as np import numpy as np
import networkx as nx import networkx as nx
from unilabos_msgs.msg import Resource
from unilabos.resources.container import RegularContainer
from unilabos.ros.msgs.message_converter import convert_from_ros_msg_with_mapping, convert_to_ros_msg
try: try:
from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.resource import Resource as ResourcePLR
@@ -80,6 +84,8 @@ def canonicalize_links_ports(data: dict) -> dict:
# 第一遍处理将字符串类型的port转换为字典格式 # 第一遍处理将字符串类型的port转换为字典格式
for link in data.get("links", []): for link in data.get("links", []):
port = link.get("port") port = link.get("port")
if link["type"] == "physical":
link["type"] = "fluid"
if isinstance(port, int): if isinstance(port, int):
port = str(port) port = str(port)
if isinstance(port, str): if isinstance(port, str):
@@ -153,7 +159,28 @@ def read_node_link_json(json_file):
physical_setup_graph = nx.node_link_graph(data, multigraph=False) # edges="links" 3.6 warning physical_setup_graph = nx.node_link_graph(data, multigraph=False) # edges="links" 3.6 warning
handle_communications(physical_setup_graph) handle_communications(physical_setup_graph)
return physical_setup_graph return physical_setup_graph, data
def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
for edge in data:
port = edge.pop("port", {})
source = edge["source"]
target = edge["target"]
if source in port:
edge["sourceHandle"] = port[source]
elif "source_port" in edge:
edge["sourceHandle"] = edge.pop("source_port")
if target in port:
edge["targetHandle"] = port[target]
elif "target_port" in edge:
edge["targetHandle"] = edge.pop("target_port")
if "id" not in edge:
edge["id"] = f"link_generated_{source}_{target}"
for key in ["source_port", "target_port"]:
if key in edge:
edge.pop(key)
return data
def read_graphml(graphml_file): def read_graphml(graphml_file):
@@ -178,7 +205,7 @@ def read_graphml(graphml_file):
physical_setup_graph = nx.node_link_graph(data, edges="links", multigraph=False) # edges="links" 3.6 warning physical_setup_graph = nx.node_link_graph(data, edges="links", multigraph=False) # edges="links" 3.6 warning
handle_communications(physical_setup_graph) handle_communications(physical_setup_graph)
return physical_setup_graph return physical_setup_graph, data
def dict_from_graph(graph: nx.Graph) -> dict: def dict_from_graph(graph: nx.Graph) -> dict:
@@ -466,6 +493,10 @@ def initialize_resource(resource_config: dict) -> list[dict]:
if resource_config.get("position") is not None: if resource_config.get("position") is not None:
r["position"] = resource_config["position"] r["position"] = resource_config["position"]
r = tree_to_list([r]) r = tree_to_list([r])
elif resource_class_config["type"] == "unilabos":
res_instance: RegularContainer = RESOURCE(id=resource_config["name"])
res_instance.ulr_resource = convert_to_ros_msg(Resource, {k:v for k,v in resource_config.items() if k != "class"})
r = [res_instance.get_ulr_resource_as_dict()]
elif isinstance(RESOURCE, dict): elif isinstance(RESOURCE, dict):
r = [RESOURCE.copy()] r = [RESOURCE.copy()]

View File

@@ -45,6 +45,7 @@ def exit() -> None:
def main( def main(
devices_config: Dict[str, Any] = {}, devices_config: Dict[str, Any] = {},
resources_config: list=[], resources_config: list=[],
resources_edge_config: list=[],
graph: Optional[Dict[str, Any]] = None, graph: Optional[Dict[str, Any]] = None,
controllers_config: Dict[str, Any] = {}, controllers_config: Dict[str, Any] = {},
bridges: List[Any] = [], bridges: List[Any] = [],
@@ -62,6 +63,7 @@ def main(
"host_node", "host_node",
devices_config, devices_config,
resources_config, resources_config,
resources_edge_config,
graph, graph,
controllers_config, controllers_config,
bridges, bridges,
@@ -97,6 +99,7 @@ def main(
def slave( def slave(
devices_config: Dict[str, Any] = {}, devices_config: Dict[str, Any] = {},
resources_config=[], resources_config=[],
resources_edge_config=[],
graph: Optional[Dict[str, Any]] = None, graph: Optional[Dict[str, Any]] = None,
controllers_config: Dict[str, Any] = {}, controllers_config: Dict[str, Any] = {},
bridges: List[Any] = [], bridges: List[Any] = [],

View File

@@ -100,7 +100,7 @@ _action_mapping: Dict[Type, Dict[str, Any]] = {
# 添加Protocol action类型到映射 # 添加Protocol action类型到映射
for py_msgtype in imsg.__all__: for py_msgtype in imsg.__all__:
if py_msgtype not in _action_mapping and py_msgtype.endswith("Protocol"): if py_msgtype not in _action_mapping and (py_msgtype.endswith("Protocol") or py_msgtype.startswith("Protocol")):
try: try:
protocol_class = msg_converter_manager.get_class(f"unilabos.messages.{py_msgtype}") protocol_class = msg_converter_manager.get_class(f"unilabos.messages.{py_msgtype}")
action_name = py_msgtype.replace("Protocol", "") action_name = py_msgtype.replace("Protocol", "")
@@ -117,6 +117,7 @@ for py_msgtype in imsg.__all__:
"result": {k: k for k in action_type.Result().get_fields_and_field_types().keys()}, "result": {k: k for k in action_type.Result().get_fields_and_field_types().keys()},
} }
except Exception: except Exception:
traceback.print_exc()
logger.debug(f"Failed to load Protocol class: {py_msgtype}") logger.debug(f"Failed to load Protocol class: {py_msgtype}")
# Python到ROS消息转换器 # Python到ROS消息转换器

View File

@@ -19,6 +19,7 @@ from rclpy.service import Service
from unilabos_msgs.action import SendCmd from unilabos_msgs.action import SendCmd
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import ( from unilabos.resources.graphio import (
convert_resources_to_type, convert_resources_to_type,
convert_resources_from_type, convert_resources_from_type,
@@ -344,6 +345,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", []) LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", [])
LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", []) LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
slot = other_calling_param.pop("slot", "-1") slot = other_calling_param.pop("slot", "-1")
resource = None
if slot != "-1": # slot为负数的时候采用assign方法 if slot != "-1": # slot为负数的时候采用assign方法
other_calling_param["slot"] = slot other_calling_param["slot"] = slot
# 本地拿到这个物料,可能需要先做初始化? # 本地拿到这个物料,可能需要先做初始化?
@@ -362,6 +364,28 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if initialize_full: if initialize_full:
resources = initialize_resources([resources]) resources = initialize_resources([resources])
request.resources = [convert_to_ros_msg(Resource, resources)] request.resources = [convert_to_ros_msg(Resource, resources)]
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1:
container_instance = request.resources[0]
container_query_dict: dict = resources
found_resources = self.resource_tracker.figure_resource({"id": container_query_dict["name"]}, try_mode=True)
if not len(found_resources):
self.resource_tracker.add_resource(container_instance)
logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器")
else:
assert len(found_resources) == 1, f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统"
resource = found_resources[0]
if isinstance(resource, Resource):
regular_container = RegularContainer(resource.id)
regular_container.ulr_resource = resource
regular_container.ulr_resource_data.update(json.loads(container_instance.data))
logger.info(f"更新物料{container_query_dict['name']}的数据{resource.data} ULR")
elif isinstance(resource, dict):
if "data" not in resource:
resource["data"] = {}
resource["data"].update(json.loads(container_instance.data))
logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict")
else:
logger.info(f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}")
response = rclient.call(request) response = rclient.call(request)
# 应该先add_resource了 # 应该先add_resource了
res.response = "OK" res.response = "OK"
@@ -385,7 +409,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
res.response = serialize_result_info(traceback.format_exc(), False, {}) res.response = serialize_result_info(traceback.format_exc(), False, {})
return res return res
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中 # 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) if bind_parent_id != self.node_name:
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) # 拿到父节点进行具体assign等操作
# request.resources = [convert_to_ros_msg(Resource, resources)] # request.resources = [convert_to_ros_msg(Resource, resources)]
try: try:
@@ -435,7 +460,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
"bind_parent_id": bind_parent_id, "bind_parent_id": bind_parent_id,
} }
) )
future = action_client.send_goal_async(goal, goal_uuid=uuid.uuid4()) future = action_client.send_goal_async(goal)
def done_cb(*args): def done_cb(*args):
self.lab_logger().info(f"向meshmanager发送新增resource完成") self.lab_logger().info(f"向meshmanager发送新增resource完成")
@@ -901,9 +926,9 @@ class ROS2DeviceNode:
from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode
if self._driver_class is ROS2ProtocolNode: if self._driver_class is ROS2ProtocolNode:
self._driver_creator = ProtocolNodeCreator(driver_class, children=children) self._driver_creator = ProtocolNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker)
else: else:
self._driver_creator = DeviceClassCreator(driver_class) self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker)
if driver_is_ros: if driver_is_ros:
driver_params["device_id"] = device_id driver_params["device_id"] = device_id

View File

@@ -58,6 +58,7 @@ class HostNode(BaseROS2DeviceNode):
device_id: str, device_id: str,
devices_config: Dict[str, Any], devices_config: Dict[str, Any],
resources_config: list, resources_config: list,
resources_edge_config: list[dict],
physical_setup_graph: Optional[Dict[str, Any]] = None, physical_setup_graph: Optional[Dict[str, Any]] = None,
controllers_config: Optional[Dict[str, Any]] = None, controllers_config: Optional[Dict[str, Any]] = None,
bridges: Optional[List[Any]] = None, bridges: Optional[List[Any]] = None,
@@ -96,6 +97,7 @@ class HostNode(BaseROS2DeviceNode):
self.server_latest_timestamp = 0.0 # self.server_latest_timestamp = 0.0 #
self.devices_config = devices_config self.devices_config = devices_config
self.resources_config = resources_config self.resources_config = resources_config
self.resources_edge_config = resources_edge_config
self.physical_setup_graph = physical_setup_graph self.physical_setup_graph = physical_setup_graph
if controllers_config is None: if controllers_config is None:
controllers_config = {} controllers_config = {}
@@ -191,24 +193,36 @@ class HostNode(BaseROS2DeviceNode):
) )
resource_with_parent_name = [] resource_with_parent_name = []
resource_ids_to_instance = {i["id"]: i for i in resources_config} resource_ids_to_instance = {i["id"]: i for i in resources_config}
resource_name_to_with_parent_name = {}
for res in resources_config: for res in resources_config:
if res.get("parent") and res.get("type") == "device" and res.get("class"): if res.get("parent") and res.get("type") == "device" and res.get("class"):
parent_id = res.get("parent") parent_id = res.get("parent")
parent_res = resource_ids_to_instance[parent_id] parent_res = resource_ids_to_instance[parent_id]
if parent_res.get("type") == "device" and parent_res.get("class"): if parent_res.get("type") == "device" and parent_res.get("class"):
resource_with_parent_name.append(copy.deepcopy(res)) resource_with_parent_name.append(copy.deepcopy(res))
resource_name_to_with_parent_name[resource_with_parent_name[-1]["id"]] = f"{parent_res['id']}/{res['id']}"
resource_with_parent_name[-1]["id"] = f"{parent_res['id']}/{res['id']}" resource_with_parent_name[-1]["id"] = f"{parent_res['id']}/{res['id']}"
continue continue
resource_with_parent_name.append(copy.deepcopy(res)) resource_with_parent_name.append(copy.deepcopy(res))
for edge in self.resources_edge_config:
edge["source"] = resource_name_to_with_parent_name.get(edge.get("source"), edge.get("source"))
edge["target"] = resource_name_to_with_parent_name.get(edge.get("target"), edge.get("target"))
try: try:
for bridge in self.bridges: for bridge in self.bridges:
if hasattr(bridge, "resource_add"): if hasattr(bridge, "resource_add"):
from unilabos.app.web.client import HTTPClient
client: HTTPClient = bridge
resource_start_time = time.time() resource_start_time = time.time()
resource_add_res = bridge.resource_add(add_schema(resource_with_parent_name), True) resource_add_res = client.resource_add(add_schema(resource_with_parent_name), True)
resource_end_time = time.time() resource_end_time = time.time()
self.lab_logger().info( self.lab_logger().info(
f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms" f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
) )
resource_add_res = client.resource_edge_add(self.resources_edge_config, True)
resource_edge_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料关系上传 {round(resource_edge_end_time - resource_end_time, 5) * 1000} ms"
)
except Exception as ex: except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!") self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc()) self.lab_logger().error(traceback.format_exc())
@@ -383,18 +397,24 @@ class HostNode(BaseROS2DeviceNode):
liquid_volume: list[int], liquid_volume: list[int],
slot_on_deck: str, slot_on_deck: str,
): ):
init_new_res = initialize_resource( res_creation_input = {
{ "name": res_id,
"name": res_id, "class": class_name,
"class": class_name, "parent": parent,
"parent": parent, "position": {
"position": { "x": bind_locations.x,
"x": bind_locations.x, "y": bind_locations.y,
"y": bind_locations.y, "z": bind_locations.z,
"z": bind_locations.z, },
}, }
} if len(liquid_input_slot) and liquid_input_slot[0] == -1: # 目前container只逐个创建
) # flatten的格式 res_creation_input.update({
"data": {
"liquid_type": liquid_type[0] if liquid_type else None,
"liquid_volume": liquid_volume[0] if liquid_volume else None,
}
})
init_new_res = initialize_resource(res_creation_input) # flatten的格式
resources = init_new_res # initialize_resource已经返回list[dict] resources = init_new_res # initialize_resource已经返回list[dict]
device_ids = [device_id] device_ids = [device_id]
bind_parent_id = [parent] bind_parent_id = [parent]
@@ -751,8 +771,10 @@ class HostNode(BaseROS2DeviceNode):
self.lab_logger().info(f"[Host Node-Resource] Add request received: {len(resources)} resources") self.lab_logger().info(f"[Host Node-Resource] Add request received: {len(resources)} resources")
success = False success = False
if len(self.bridges) > 0: if len(self.bridges) > 0: # 边的提交待定
r = self.bridges[-1].resource_add(add_schema(resources)) from unilabos.app.web.client import HTTPClient
client: HTTPClient = self.bridges[-1]
r = client.resource_add(add_schema(resources), False)
success = bool(r) success = bool(r)
response.success = success response.success = success

View File

@@ -25,7 +25,7 @@ class DeviceNodeResourceTracker(object):
def clear_resource(self): def clear_resource(self):
self.resources = [] self.resources = []
def figure_resource(self, query_resource): def figure_resource(self, query_resource, try_mode=False):
if isinstance(query_resource, list): if isinstance(query_resource, list):
return [self.figure_resource(r) for r in query_resource] return [self.figure_resource(r) for r in query_resource]
res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) else None) res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) else None)
@@ -45,10 +45,14 @@ class DeviceNodeResourceTracker(object):
res_list.extend( res_list.extend(
self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key)) self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key))
) )
assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}" if not try_mode:
assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在"
assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}"
else:
return [i[1] for i in res_list]
# 后续加入其他对比方式
self.resource2parent_resource[id(query_resource)] = res_list[0][0] self.resource2parent_resource[id(query_resource)] = res_list[0][0]
self.resource2parent_resource[id(res_list[0][1])] = res_list[0][0] self.resource2parent_resource[id(res_list[0][1])] = res_list[0][0]
# 后续加入其他对比方式
return res_list[0][1] return res_list[0][1]
def loop_find_resource(self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None) -> List[Tuple[Any, Any]]: def loop_find_resource(self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None) -> List[Tuple[Any, Any]]:
@@ -57,8 +61,12 @@ class DeviceNodeResourceTracker(object):
children = getattr(resource, "children", []) children = getattr(resource, "children", [])
for child in children: for child in children:
res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource)) res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource))
if target_resource_cls_type == type(resource) or target_resource_cls_type == dict: if target_resource_cls_type == type(resource):
if hasattr(resource, identifier_key): if target_resource_cls_type == dict:
if identifier_key in resource:
if resource[identifier_key] == compare_value:
res_list.append((parent_res, resource))
elif hasattr(resource, identifier_key):
if getattr(resource, identifier_key) == compare_value: if getattr(resource, identifier_key) == compare_value:
res_list.append((parent_res, resource)) res_list.append((parent_res, resource))
return res_list return res_list

View File

@@ -33,7 +33,7 @@ class DeviceClassCreator(Generic[T]):
这个类提供了从任意类创建实例的通用方法。 这个类提供了从任意类创建实例的通用方法。
""" """
def __init__(self, cls: Type[T]): def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker):
""" """
初始化设备类创建器 初始化设备类创建器
@@ -42,6 +42,18 @@ class DeviceClassCreator(Generic[T]):
""" """
self.device_cls = cls self.device_cls = cls
self.device_instance: Optional[T] = None self.device_instance: Optional[T] = None
self.children = children
self.resource_tracker = resource_tracker
def attach_resource(self):
"""
附加资源到设备类实例
"""
if self.device_instance is not None:
for c in self.children.values():
if c["type"] == "container":
self.resource_tracker.add_resource(c)
def create_instance(self, data: Dict[str, Any]) -> T: def create_instance(self, data: Dict[str, Any]) -> T:
""" """
@@ -60,6 +72,7 @@ class DeviceClassCreator(Generic[T]):
} }
) )
self.post_create() self.post_create()
self.attach_resource()
return self.device_instance return self.device_instance
def get_instance(self) -> Optional[T]: def get_instance(self) -> Optional[T]:
@@ -90,14 +103,15 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
cls: PyLabRobot设备类 cls: PyLabRobot设备类
children: 子资源字典,用于资源替换 children: 子资源字典,用于资源替换
""" """
super().__init__(cls) super().__init__(cls, children, resource_tracker)
self.children = children
self.resource_tracker = resource_tracker
# 检查类是否具有deserialize方法 # 检查类是否具有deserialize方法
self.has_deserialize = hasattr(cls, "deserialize") and callable(getattr(cls, "deserialize")) self.has_deserialize = hasattr(cls, "deserialize") and callable(getattr(cls, "deserialize"))
if not self.has_deserialize: if not self.has_deserialize:
logger.warning(f"{cls.__name__} 没有deserialize方法将使用标准构造函数") logger.warning(f"{cls.__name__} 没有deserialize方法将使用标准构造函数")
def attach_resource(self):
pass # 只能增加实例化物料,原来默认物料仅为字典查询
def _process_resource_mapping(self, resource, source_type): def _process_resource_mapping(self, resource, source_type):
if source_type == dict: if source_type == dict:
from pylabrobot.resources.resource import Resource from pylabrobot.resources.resource import Resource
@@ -260,7 +274,7 @@ class ProtocolNodeCreator(DeviceClassCreator[T]):
这个类提供了针对ProtocolNode设备类的实例创建方法处理children参数。 这个类提供了针对ProtocolNode设备类的实例创建方法处理children参数。
""" """
def __init__(self, cls: Type[T], children: Dict[str, Any]): def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker):
""" """
初始化ProtocolNode设备类创建器 初始化ProtocolNode设备类创建器
@@ -268,8 +282,7 @@ class ProtocolNodeCreator(DeviceClassCreator[T]):
cls: ProtocolNode设备类 cls: ProtocolNode设备类
children: 子资源字典,用于资源替换 children: 子资源字典,用于资源替换
""" """
super().__init__(cls) super().__init__(cls, children, resource_tracker)
self.children = children
def create_instance(self, data: Dict[str, Any]) -> T: def create_instance(self, data: Dict[str, Any]) -> T:
""" """
@@ -282,8 +295,7 @@ class ProtocolNodeCreator(DeviceClassCreator[T]):
ProtocolNode设备类实例 ProtocolNode设备类实例
""" """
try: try:
# 创建实例额外补充一个给protocol node的字段后面考虑取消
# 创建实例
data["children"] = self.children data["children"] = self.children
self.device_instance = super(ProtocolNodeCreator, self).create_instance(data) self.device_instance = super(ProtocolNodeCreator, self).create_instance(data)
self.post_create() self.post_create()