Merge branch '37-biomek-i5i7' into device_visualization

This commit is contained in:
Xuwznln
2025-06-08 17:08:48 +08:00
75 changed files with 20011 additions and 131 deletions

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.1-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.2-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.1-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.2-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.1 version: 0.9.2
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
folder: ros-humble-unilabos-msgs/src/work folder: ros-humble-unilabos-msgs/src/work

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
{
"nodes": [
{
"id": "BIOMEK",
"name": "BIOMEK",
"parent": null,
"type": "device",
"class": "liquid_handler.biomek",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {},
"children": [
]
}
],
"links": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ from copy import deepcopy
import yaml import yaml
from unilabos.resources.graphio import tree_to_list
# 首先添加项目根目录到路径 # 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir)) unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -144,19 +146,19 @@ def main():
else read_graphml(args_dict["graph"]) else read_graphml(args_dict["graph"])
) )
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["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False) args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
# args_dict["resources_config"] = dict_to_tree(devices_and_resources, devices_only=False)
args_dict["graph"] = graph_res.physical_setup_graph args_dict["graph"] = graph_res.physical_setup_graph
else: else:
if args_dict["devices"] is None or args_dict["resources"] is None: if args_dict["devices"] is None or args_dict["resources"] is None:
print_status("Either graph or devices and resources must be provided.", "error") print_status("Either graph or devices and resources must be provided.", "error")
sys.exit(1) sys.exit(1)
args_dict["devices_config"] = json.load(open(args_dict["devices"], encoding="utf-8")) args_dict["devices_config"] = json.load(open(args_dict["devices"], encoding="utf-8"))
args_dict["resources_config"] = initialize_resources( # args_dict["resources_config"] = initialize_resources(
list(json.load(open(args_dict["resources"], encoding="utf-8")).values()) # list(json.load(open(args_dict["resources"], encoding="utf-8")).values())
) # )
args_dict["resources_config"] = list(json.load(open(args_dict["resources"], encoding="utf-8")).values())
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info") print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
for i in args_dict["resources_config"]: for i in args_dict["resources_config"]:

View File

@@ -1,6 +1,7 @@
import json import json
import time import time
import traceback import traceback
from typing import Optional
import uuid import uuid
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@@ -163,10 +164,12 @@ class MQTTClient:
self.client.publish(address, json.dumps(status), qos=2) self.client.publish(address, json.dumps(status), qos=2)
logger.critical(f"Device status published: address: {address}, {status}") logger.critical(f"Device status published: address: {address}, {status}")
def publish_job_status(self, feedback_data: dict, job_id: str, status: str): def publish_job_status(self, feedback_data: dict, job_id: str, status: str, return_info: Optional[str] = None):
if self.mqtt_disable: if self.mqtt_disable:
return return
jobdata = {"job_id": job_id, "data": feedback_data, "status": status} if return_info is None:
return_info = "{}"
jobdata = {"job_id": job_id, "data": feedback_data, "status": status, "return_info": return_info}
self.client.publish(f"labs/{MQConfig.lab_id}/job/list/", json.dumps(jobdata), qos=2) self.client.publish(f"labs/{MQConfig.lab_id}/job/list/", json.dumps(jobdata), qos=2)
def publish_registry(self, device_id: str, device_info: dict): def publish_registry(self, device_id: str, device_info: dict):

View File

@@ -30,18 +30,18 @@ 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]]) -> requests.Response: def resource_add(self, resources: List[Dict[str, Any]], database_process_later:bool) -> requests.Response:
""" """
添加资源 添加资源
Args: Args:
resources: 要添加的资源列表 resources: 要添加的资源列表
database_process_later: 后台处理资源
Returns: Returns:
Response: API响应对象 Response: API响应对象
""" """
response = requests.post( response = requests.post(
f"{self.remote_addr}/lab/resource/", f"{self.remote_addr}/lab/resource/?database_process_later={1 if database_process_later else 0}",
json=resources, json=resources,
headers={"Authorization": f"lab {self.auth}"}, headers={"Authorization": f"lab {self.auth}"},
timeout=5, timeout=5,

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,8 @@ import asyncio
import time import time
from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.resources import ( from pylabrobot.resources import Resource, TipRack, Container, Coordinate, Well
Resource,
TipRack,
Container,
Coordinate,
Well
)
class LiquidHandlerAbstract(LiquidHandler): class LiquidHandlerAbstract(LiquidHandler):
"""Extended LiquidHandler with additional operations.""" """Extended LiquidHandler with additional operations."""
@@ -21,6 +16,19 @@ class LiquidHandlerAbstract(LiquidHandler):
# REMOVE LIQUID -------------------------------------------------- # REMOVE LIQUID --------------------------------------------------
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def create_protocol(
self,
protocol_name: str,
protocol_description: str,
protocol_version: str,
protocol_author: str,
protocol_date: str,
protocol_type: str,
none_keys: List[str] = [],
):
"""Create a new protocol with the given metadata."""
pass
async def remove_liquid( async def remove_liquid(
self, self,
vols: List[float], vols: List[float],
@@ -35,8 +43,8 @@ class LiquidHandlerAbstract(LiquidHandler):
spread: Optional[Literal["wide", "tight", "custom"]] = "wide", spread: Optional[Literal["wide", "tight", "custom"]] = "wide",
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
is_96_well: Optional[bool] = False, is_96_well: Optional[bool] = False,
top: Optional[List(float)] = None, top: Optional[List[float]] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
"""A complete *remove* (aspirate → waste) operation.""" """A complete *remove* (aspirate → waste) operation."""
trash = self.deck.get_trash_area() trash = self.deck.get_trash_area()
@@ -48,7 +56,7 @@ class LiquidHandlerAbstract(LiquidHandler):
raise ValueError("Length of `vols` must match `sources`.") raise ValueError("Length of `vols` must match `sources`.")
for src, vol in zip(sources, vols): for src, vol in zip(sources, vols):
self.move_to(src, dis_to_top=top[0] if top else 0) await self.move_to(src, dis_to_top=top[0] if top else 0)
tip = next(self.current_tip) tip = next(self.current_tip)
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.aspirate( await self.aspirate(
@@ -100,7 +108,7 @@ class LiquidHandlerAbstract(LiquidHandler):
mix_vol: Optional[int] = None, mix_vol: Optional[int] = None,
mix_rate: Optional[int] = None, mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
"""A complete *add* (aspirate reagent → dispense into targets) operation.""" """A complete *add* (aspirate reagent → dispense into targets) operation."""
@@ -122,7 +130,7 @@ class LiquidHandlerAbstract(LiquidHandler):
offsets=[offsets[0]] if offsets else None, offsets=[offsets[0]] if offsets else None,
liquid_height=[liquid_height[0]] if liquid_height else None, liquid_height=[liquid_height[0]] if liquid_height else None,
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None,
spread=spread spread=spread,
) )
if delays is not None: if delays is not None:
await self.custom_delay(seconds=delays[0]) await self.custom_delay(seconds=delays[0])
@@ -144,7 +152,8 @@ class LiquidHandlerAbstract(LiquidHandler):
mix_vol=mix_vol, mix_vol=mix_vol,
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None) mix_rate=mix_rate if mix_rate else None,
)
if delays is not None: if delays is not None:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_]) await self.touch_tip(targets[_])
@@ -158,13 +167,13 @@ class LiquidHandlerAbstract(LiquidHandler):
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def transfer_liquid( async def transfer_liquid(
self, self,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
sources: Sequence[Container], sources: Sequence[Container],
targets: Sequence[Container], targets: Sequence[Container],
tip_racks: Sequence[TipRack], tip_racks: Sequence[TipRack],
*, *,
use_channels: Optional[List[int]] = None, use_channels: Optional[List[int]] = None,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
asp_flow_rates: Optional[List[Optional[float]]] = None, asp_flow_rates: Optional[List[Optional[float]]] = None,
dis_flow_rates: Optional[List[Optional[float]]] = None, dis_flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None, offsets: Optional[List[Coordinate]] = None,
@@ -179,7 +188,7 @@ class LiquidHandlerAbstract(LiquidHandler):
mix_rate: Optional[int] = None, mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
"""Transfer liquid from each *source* well/plate to the corresponding *target*. """Transfer liquid from each *source* well/plate to the corresponding *target*.
@@ -207,8 +216,9 @@ class LiquidHandlerAbstract(LiquidHandler):
raise ValueError("`sources`, `targets`, and `vols` must have the same length.") raise ValueError("`sources`, `targets`, and `vols` must have the same length.")
tip_iter = self.iter_tips(tip_racks) tip_iter = self.iter_tips(tip_racks)
for src, tgt, asp_vol, asp_flow_rate, dis_vol, dis_flow_rate in ( for src, tgt, asp_vol, asp_flow_rate, dis_vol, dis_flow_rate in zip(
zip(sources, targets, asp_vols, asp_flow_rates, dis_vols, dis_flow_rates)): sources, targets, asp_vols, asp_flow_rates, dis_vols, dis_flow_rates
):
tip = next(tip_iter) tip = next(tip_iter)
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
# Aspirate from source # Aspirate from source
@@ -247,9 +257,9 @@ class LiquidHandlerAbstract(LiquidHandler):
except Exception as exc: except Exception as exc:
raise RuntimeError(f"Liquid transfer failed: {exc}") from exc raise RuntimeError(f"Liquid transfer failed: {exc}") from exc
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Helper utilities # Helper utilities
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def custom_delay(self, seconds=0, msg=None): async def custom_delay(self, seconds=0, msg=None):
""" """
@@ -266,28 +276,26 @@ class LiquidHandlerAbstract(LiquidHandler):
print(f"Done: {msg}") print(f"Done: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}") print(f"Current time: {time.strftime('%H:%M:%S')}")
async def touch_tip(self, async def touch_tip(self, targets: Sequence[Container]):
targets: Sequence[Container],
):
"""Touch the tip to the side of the well.""" """Touch the tip to the side of the well."""
await self.aspirate( await self.aspirate(
resources=[targets], resources=[targets],
vols=[0], vols=[0],
use_channels=None, use_channels=None,
flow_rates=None, flow_rates=None,
offsets=[Coordinate(x=-targets.get_size_x()/2,y=0,z=0)], offsets=[Coordinate(x=-targets.get_size_x() / 2, y=0, z=0)],
liquid_height=None, liquid_height=None,
blow_out_air_volume=None blow_out_air_volume=None,
) )
#await self.custom_delay(seconds=1) # In the simulation, we do not need to wait # await self.custom_delay(seconds=1) # In the simulation, we do not need to wait
await self.aspirate( await self.aspirate(
resources=[targets], resources=[targets],
vols=[0], vols=[0],
use_channels=None, use_channels=None,
flow_rates=None, flow_rates=None,
offsets=[Coordinate(x=targets.get_size_x()/2,y=0,z=0)], offsets=[Coordinate(x=targets.get_size_x() / 2, y=0, z=0)],
liquid_height=None, liquid_height=None,
blow_out_air_volume=None blow_out_air_volume=None,
) )
async def mix( async def mix(
@@ -298,7 +306,7 @@ class LiquidHandlerAbstract(LiquidHandler):
height_to_bottom: Optional[float] = None, height_to_bottom: Optional[float] = None,
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
if mix_time is None: # No mixing required if mix_time is None: # No mixing required
return return
@@ -333,7 +341,7 @@ class LiquidHandlerAbstract(LiquidHandler):
tip_iter = self.iter_tips(tip_racks) tip_iter = self.iter_tips(tip_racks)
self.current_tip = tip_iter self.current_tip = tip_iter
async def move_to(self, well: Well, dis_to_top: float = 0 , channel: int = 0): async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0):
""" """
Move a single channel to a specific well with a given z-height. Move a single channel to a specific well with a given z-height.
@@ -352,4 +360,3 @@ class LiquidHandlerAbstract(LiquidHandler):
await self.move_channel_x(channel, abs_loc.x) await self.move_channel_x(channel, abs_loc.x)
await self.move_channel_y(channel, abs_loc.y) await self.move_channel_y(channel, abs_loc.y)
await self.move_channel_z(channel, abs_loc.z + well_height + dis_to_top) await self.move_channel_z(channel, abs_loc.z + well_height + dis_to_top)

View File

@@ -0,0 +1,154 @@
import json
from typing import Sequence, Optional, List, Union, Literal
json_path = "/Users/guangxinzhang/Documents/Deep Potential/opentrons/convert/protocols/enriched_steps/sci-lucif-assay4.json"
with open(json_path, "r") as f:
data = json.load(f)
transfer_example = data[0]
#print(transfer_example)
temp_protocol = []
TipLocation = "BC1025F" # Assuming this is a fixed tip location for the transfer
sources = transfer_example["sources"] # Assuming sources is a list of Container objects
targets = transfer_example["targets"] # Assuming targets is a list of Container objects
tip_racks = transfer_example["tip_racks"] # Assuming tip_racks is a list of TipRack objects
asp_vols = transfer_example["asp_vols"] # Assuming asp_vols is a list of volumes
solvent = "PBS"
def transfer_liquid(
#self,
sources,#: Sequence[Container],
targets,#: Sequence[Container],
tip_racks,#: Sequence[TipRack],
TipLocation,
# *,
# use_channels: Optional[List[int]] = None,
asp_vols: Union[List[float], float],
solvent: Optional[str] = None,
# dis_vols: Union[List[float], float],
# asp_flow_rates: Optional[List[Optional[float]]] = None,
# dis_flow_rates: Optional[List[Optional[float]]] = None,
# offsets,#: Optional[List[]] = None,
# touch_tip: bool = False,
# liquid_height: Optional[List[Optional[float]]] = None,
# blow_out_air_volume: Optional[List[Optional[float]]] = None,
# spread: Literal["wide", "tight", "custom"] = "wide",
# is_96_well: bool = False,
# mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
# mix_times,#: Optional[list() = None,
# mix_vol: Optional[int] = None,
# mix_rate: Optional[int] = None,
# mix_liquid_height: Optional[float] = None,
# delays: Optional[List[int]] = None,
# none_keys: List[str] = []
):
# -------- Build Biomek transfer step --------
# 1) Construct default parameter scaffold (values mirror Biomek “Transfer” block).
transfer_params = {
"Span8": False,
"Pod": "Pod1",
"items": {}, # to be filled below
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": False,
"ChangeTipsBetweenSources": False,
"DefaultCaption": "", # filled after we know first pair/vol
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Solvent": "Water",
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": False,
"UseDisposableTips": True,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "1",
"WashVolume": "110%",
"Wizard": False
}
items: dict = {}
for idx, (src, dst) in enumerate(zip(sources, targets)):
items[str(idx)] = {
"Source": str(src),
"Destination": str(dst),
"Volume": asp_vols[idx]
}
transfer_params["items"] = items
transfer_params["Solvent"] = solvent if solvent else "Water"
transfer_params["TipLocation"] = TipLocation
if len(tip_racks) == 1:
transfer_params['UseCurrentTips'] = True
elif len(tip_racks) > 1:
transfer_params["ChangeTipsBetweenDests"] = True
return transfer_params
action = transfer_liquid(sources=sources,targets=targets,tip_racks=tip_racks, asp_vols=asp_vols,solvent = solvent, TipLocation=TipLocation)
print(json.dumps(action,indent=2))
# print(action)
"""
"transfer": {
"items": {},
"Wash": false,
"Dynamic?": true,
"AutoSelectActiveWashTechnique": false,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": true,
"ChangeTipsBetweenSources": false,
"DefaultCaption": "Transfer 100 µL from P13 to P3",
"UseExpression": false,
"LeaveTipsOn": false,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": false,
"Replicates": "1",
"ShowTipHandlingDetails": true,
"ShowTransferDetails": true,
"Span8Wash": false,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": false,
"SplitVolumeCleaning": false,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": false,
"UseDisposableTips": false,
"UseFixedTips": false,
"UseJIT": true,
"UseMandrelSelection": true,
"UseProbes": [true, true, true, true, true, true, true, true],
"WashCycles": "3",
"WashVolume": "110%",
"Wizard": false
"""

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
liquid_handler: liquid_handler:
description: Liquid handler device controlled by pylabrobot description: Liquid handler device controlled by pylabrobot
icon: icon_yiyezhan.webp
class: class:
module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract
type: python type: python
@@ -22,8 +23,8 @@ liquid_handler:
is_96_well: is_96_well is_96_well: is_96_well
top: top top: top
none_keys: none_keys none_keys: none_keys
feedback: { } feedback: {}
result: { } result: {}
add_liquid: add_liquid:
type: LiquidHandlerAdd type: LiquidHandlerAdd
goal: goal:
@@ -43,8 +44,8 @@ liquid_handler:
mix_rate: mix_rate mix_rate: mix_rate
mix_liquid_height: mix_liquid_height mix_liquid_height: mix_liquid_height
none_keys: none_keys none_keys: none_keys
feedback: { } feedback: {}
result: { } result: {}
transfer_liquid: transfer_liquid:
type: LiquidHandlerTransfer type: LiquidHandlerTransfer
goal: goal:
@@ -69,8 +70,8 @@ liquid_handler:
mix_liquid_height: mix_liquid_height mix_liquid_height: mix_liquid_height
delays: delays delays: delays
none_keys: none_keys none_keys: none_keys
feedback: { } feedback: {}
result: { } result: {}
mix: mix:
type: LiquidHandlerMix type: LiquidHandlerMix
goal: goal:
@@ -81,16 +82,16 @@ liquid_handler:
offsets: offsets offsets: offsets
mix_rate: mix_rate mix_rate: mix_rate
none_keys: none_keys none_keys: none_keys
feedback: { } feedback: {}
result: { } result: {}
move_to: move_to:
type: LiquidHandlerMoveTo type: LiquidHandlerMoveTo
goal: goal:
well: well well: well
dis_to_top: dis_to_top dis_to_top: dis_to_top
channel: channel channel: channel
feedback: { } feedback: {}
result: { } result: {}
aspirate: aspirate:
type: LiquidHandlerAspirate type: LiquidHandlerAspirate
goal: goal:
@@ -245,6 +246,21 @@ liquid_handler:
target_vols: target_vols target_vols: target_vols
aspiration_flow_rate: aspiration_flow_rate aspiration_flow_rate: aspiration_flow_rate
dispense_flow_rates: dispense_flow_rates dispense_flow_rates: dispense_flow_rates
handles:
input:
- handler_key: liquid-input
label: Liquid Input
data_type: resource
io_type: target
data_source: handle
data_key: liquid
output:
- handler_key: liquid-output
label: Liquid Output
data_type: resource
io_type: source
data_source: executor
data_key: liquid
schema: schema:
type: object type: object
properties: properties:
@@ -272,3 +288,174 @@ liquid_handler.revvity:
status: status status: status
result: result:
success: success success: success
liquid_handler.biomek:
description: Biomek液体处理器设备基于pylabrobot控制
icon: icon_yiyezhan.webp
class:
module: unilabos.devices.liquid_handling.biomek:LiquidHandlerBiomek
type: python
status_types: {}
action_value_mappings:
create_protocol:
type: LiquidHandlerProtocolCreation
goal:
protocol_name: protocol_name
protocol_description: protocol_description
protocol_version: protocol_version
protocol_author: protocol_author
protocol_date: protocol_date
protocol_type: protocol_type
none_keys: none_keys
feedback: {}
result: {}
run_protocol:
type: EmptyIn
goal: {}
feedback: {}
result: {}
transfer_liquid:
type: LiquidHandlerTransfer
goal:
asp_vols: asp_vols
dis_vols: dis_vols
sources: sources
targets: targets
tip_racks: tip_racks
use_channels: use_channels
asp_flow_rates: asp_flow_rates
dis_flow_rates: dis_flow_rates
offsets: offsets
touch_tip: touch_tip
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
delays: delays
none_keys: none_keys
feedback: {}
result: {}
handles:
input:
- handler_key: liquid-input
label: Liquid Input
data_type: resource
io_type: target
data_source: handle
data_key: liquid
output:
- handler_key: liquid-output
label: Liquid Output
data_type: resource
io_type: source
data_source: executor
data_key: liquid
transfer_biomek:
type: LiquidHandlerTransferBiomek
goal:
source: source
target: target
tip_rack: tip_rack
volume: volume
aspirate_techniques: aspirate_techniques
dispense_techniques: dispense_techniques
feedback: {}
result: {}
handles:
input:
- handler_key: sources
label: sources
data_type: resource
data_source: handle
data_key: liquid
- handler_key: targets
label: targets
data_type: resource
data_source: executor
data_key: liquid
- handler_key: tip_rack
label: tip_rack
data_type: resource
data_source: executor
data_key: liquid
output:
- handler_key: sources_out
label: sources
data_type: resource
data_source: handle
data_key: liquid
- handler_key: targets_out
label: targets
data_type: resource
data_source: executor
data_key: liquid
oscillation_biomek:
type: LiquidHandlerOscillateBiomek
goal:
rpm: rpm
time: time
feedback: {}
result: {}
handles:
input:
- handler_key: plate
label: plate
data_type: resource
data_source: handle
data_key: liquid
output:
- handler_key: plate_out
label: plate
data_type: resource
data_source: handle
data_key: liquid
move_biomek:
type: LiquidHandlerMoveBiomek
goal:
source: resource
target: target
feedback: {}
result:
name: name
handles:
input:
- handler_key: sources
label: sources
data_type: resource
data_source: handle
data_key: liquid
output:
- handler_key: targets
label: targets
data_type: resource
data_source: handle
data_key: liquid
incubation_biomek:
type: LiquidHandlerIncubateBiomek
goal:
time: time
feedback: {}
result: {}
handles:
input:
- handler_key: plate
label: plate
data_type: resource
data_source: handle
data_key: liquid
output:
- handler_key: plate_out
label: plate
data_type: resource
data_source: handle
data_key: liquid
schema:
type: object
properties: {}
required: []
additionalProperties: false

View File

@@ -28,12 +28,43 @@ syringe_pump_with_valve.runze:
- valve_position - valve_position
additionalProperties: false additionalProperties: false
solenoid_valve.mock: solenoid_valve.mock:
description: Mock solenoid valve description: Mock solenoid valve
class: class:
module: unilabos.devices.pump_and_valve.solenoid_valve_mock:SolenoidValveMock module: unilabos.devices.pump_and_valve.solenoid_valve_mock:SolenoidValveMock
type: python type: python
status_types:
status: String
valve_position: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
handles:
input:
- handler_key: fluid-input
label: Fluid Input
data_type: fluid
output:
- handler_key: fluid-output
label: Fluid Output
data_type: fluid
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "COM6"
required:
- port
solenoid_valve: solenoid_valve:
description: Solenoid valve description: Solenoid valve

View File

@@ -22,9 +22,76 @@ vacuum_pump.mock:
string: string string: string
feedback: {} feedback: {}
result: {} result: {}
handles:
input:
- handler_key: fluid-input
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
io_type: source
data_source: executor
data_key: fluid_out
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "COM6"
required:
- port
gas_source.mock: gas_source.mock:
description: Mock gas source description: Mock gas source
class: class:
module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock
type: python 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:
input:
- handler_key: fluid-input
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
io_type: source
data_source: executor
data_key: fluid_out
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "COM6"
required:
- port

View File

@@ -25,9 +25,7 @@ class Registry:
self.ResourceCreateFromOuterEasy = self._replace_type_with_class( self.ResourceCreateFromOuterEasy = self._replace_type_with_class(
"ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource" "ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
) )
self.EmptyIn = self._replace_type_with_class( self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
"EmptyIn", "host_node", f""
)
self.device_type_registry = {} self.device_type_registry = {}
self.resource_type_registry = {} self.resource_type_registry = {}
self._setup_called = False # 跟踪setup是否已调用 self._setup_called = False # 跟踪setup是否已调用
@@ -66,6 +64,7 @@ class Registry:
"goal_default": yaml.safe_load( "goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuter.Goal)) io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuter.Goal))
), ),
"handles": {},
}, },
"create_resource": { "create_resource": {
"type": self.ResourceCreateFromOuterEasy, "type": self.ResourceCreateFromOuterEasy,
@@ -86,6 +85,15 @@ class Registry:
"goal_default": yaml.safe_load( "goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuterEasy.Goal)) io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuterEasy.Goal))
), ),
"handles": {
"output": [{
"handler_key": "Labware",
"label": "Labware",
"data_type": "resource",
"data_source": "handle",
"data_key": "liquid"
}]
},
}, },
"test_latency": { "test_latency": {
"type": self.EmptyIn, "type": self.EmptyIn,
@@ -94,11 +102,14 @@ class Registry:
"result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"}, "result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"},
"schema": ros_action_to_json_schema(self.EmptyIn), "schema": ros_action_to_json_schema(self.EmptyIn),
"goal_default": {}, "goal_default": {},
"handles": {},
}, },
}, },
}, },
"icon": "icon_device.webp", "icon": "icon_device.webp",
"registry_type": "device", "registry_type": "device",
"handles": [],
"init_param_schema": {},
"schema": {"properties": {}, "additionalProperties": False, "type": "object"}, "schema": {"properties": {}, "additionalProperties": False, "type": "object"},
"file_path": "/", "file_path": "/",
} }
@@ -132,6 +143,10 @@ class Registry:
resource_info["description"] = "" resource_info["description"] = ""
if "icon" not in resource_info: if "icon" not in resource_info:
resource_info["icon"] = "" resource_info["icon"] = ""
if "handles" not in resource_info:
resource_info["handles"] = []
if "init_param_schema" not in resource_info:
resource_info["init_param_schema"] = {}
resource_info["registry_type"] = "resource" resource_info["registry_type"] = "resource"
self.resource_type_registry.update(data) self.resource_type_registry.update(data)
logger.debug( logger.debug(
@@ -194,6 +209,10 @@ class Registry:
device_config["description"] = "" device_config["description"] = ""
if "icon" not in device_config: if "icon" not in device_config:
device_config["icon"] = "" device_config["icon"] = ""
if "handles" not in device_config:
device_config["handles"] = []
if "init_param_schema" not in device_config:
device_config["init_param_schema"] = {}
device_config["registry_type"] = "device" device_config["registry_type"] = "device"
if "class" in device_config: if "class" in device_config:
# 处理状态类型 # 处理状态类型
@@ -206,6 +225,8 @@ class Registry:
# 处理动作值映射 # 处理动作值映射
if "action_value_mappings" in device_config["class"]: if "action_value_mappings" in device_config["class"]:
for action_name, action_config in device_config["class"]["action_value_mappings"].items(): for action_name, action_config in device_config["class"]["action_value_mappings"].items():
if "handles" not in action_config:
action_config["handles"] = []
if "type" in action_config: if "type" in action_config:
action_config["type"] = self._replace_type_with_class( action_config["type"] = self._replace_type_with_class(
action_config["type"], device_id, f"动作 {action_name}" action_config["type"], device_id, f"动作 {action_name}"

View File

@@ -131,7 +131,7 @@ _msg_converter: Dict[Type, Any] = {
Bool: lambda x: Bool(data=bool(x)), Bool: lambda x: Bool(data=bool(x)),
str: str, str: str,
String: lambda x: String(data=str(x)), String: lambda x: String(data=str(x)),
Point: lambda x: Point(x=x.x, y=x.y, z=x.z), Point: lambda x: Point(x=x.x, y=x.y, z=x.z) if not isinstance(x, dict) else Point(x=x.get("x", 0), y=x.get("y", 0), z=x.get("z", 0)),
Resource: lambda x: Resource( Resource: lambda x: Resource(
id=x.get("id", ""), id=x.get("id", ""),
name=x.get("name", ""), name=x.get("name", ""),

View File

@@ -1,5 +1,4 @@
import copy import copy
import functools
import json import json
import threading import threading
import time import time
@@ -20,16 +19,29 @@ 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.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \ from unilabos.resources.graphio import (
initialize_resources, list_to_nested_dict, dict_to_tree, resource_plr_to_ulab, tree_to_list convert_resources_to_type,
convert_resources_from_type,
resource_ulab_to_plr,
initialize_resources,
dict_to_tree,
resource_plr_to_ulab,
tree_to_list,
)
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg, convert_to_ros_msg,
convert_from_ros_msg, convert_from_ros_msg,
convert_from_ros_msg_with_mapping, convert_from_ros_msg_with_mapping,
convert_to_ros_msg_with_mapping, ros_action_to_json_schema, convert_to_ros_msg_with_mapping,
) )
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \ from unilabos_msgs.srv import (
SerialCommand # type: ignore ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
) # type: ignore
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
@@ -37,7 +49,7 @@ from unilabos.ros.x.rclpyx import get_event_loop
from unilabos.ros.utils.driver_creator import ProtocolNodeCreator, PyLabRobotCreator, DeviceClassCreator from unilabos.ros.utils.driver_creator import ProtocolNodeCreator, PyLabRobotCreator, DeviceClassCreator
from unilabos.utils.async_util import run_async_func from unilabos.utils.async_util import run_async_func
from unilabos.utils.log import info, debug, warning, error, critical, logger from unilabos.utils.log import info, debug, warning, error, critical, logger
from unilabos.utils.type_check import get_type_class, TypeEncoder from unilabos.utils.type_check import get_type_class, TypeEncoder, serialize_result_info
T = TypeVar("T") T = TypeVar("T")
@@ -292,7 +304,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.create_ros_action_server(action_name, action_value_mapping) self.create_ros_action_server(action_name, action_value_mapping)
# 创建线程池执行器 # 创建线程池执行器
self._executor = ThreadPoolExecutor(max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}") self._executor = ThreadPoolExecutor(
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
)
# 创建资源管理客户端 # 创建资源管理客户端
self._resource_clients: Dict[str, Client] = { self._resource_clients: Dict[str, Client] = {
@@ -334,7 +348,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
other_calling_param["slot"] = slot other_calling_param["slot"] = slot
# 本地拿到这个物料,可能需要先做初始化? # 本地拿到这个物料,可能需要先做初始化?
if isinstance(resources, list): if isinstance(resources, list):
if len(resources) == 1 and isinstance(resources[0], list) and not initialize_full: # 取消,不存在的情况 if (
len(resources) == 1 and isinstance(resources[0], list) and not initialize_full
): # 取消,不存在的情况
# 预先initialize过以整组的形式传入 # 预先initialize过以整组的形式传入
request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]] request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]]
elif initialize_full: elif initialize_full:
@@ -349,6 +365,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
response = rclient.call(request) response = rclient.call(request)
# 应该先add_resource了 # 应该先add_resource了
res.response = "OK" res.response = "OK"
# 如果driver自己就有assign的方法那就使用driver自己的assign方法
if hasattr(self.driver_instance, "create_resource"):
create_resource_func = getattr(self.driver_instance, "create_resource")
create_resource_func(
resource_tracker=self.resource_tracker,
resources=request.resources,
bind_parent_id=bind_parent_id,
bind_location=location,
liquid_input_slot=LIQUID_INPUT_SLOT,
liquid_type=ADD_LIQUID_TYPE,
liquid_volume=LIQUID_VOLUME,
slot_on_deck=slot,
)
return res
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中 # 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
# request.resources = [convert_to_ros_msg(Resource, resources)] # request.resources = [convert_to_ros_msg(Resource, resources)]
@@ -359,6 +389,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import OTDeck from pylabrobot.resources import OTDeck
from pylabrobot.resources import Plate from pylabrobot.resources import Plate
contain_model = not isinstance(resource, Deck) contain_model = not isinstance(resource, Deck)
if isinstance(resource, ResourcePLR): if isinstance(resource, ResourcePLR):
# resources.list() # resources.list()
@@ -366,25 +397,38 @@ class BaseROS2DeviceNode(Node, Generic[T]):
plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model) plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model)
if isinstance(plr_instance, Plate): if isinstance(plr_instance, Plate):
empty_liquid_info_in = [(None, 0)] * plr_instance.num_items empty_liquid_info_in = [(None, 0)] * plr_instance.num_items
for liquid_type, liquid_volume, liquid_input_slot in zip(ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT): for liquid_type, liquid_volume, liquid_input_slot in zip(
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in) plr_instance.set_well_liquids(empty_liquid_info_in)
if isinstance(resource, OTDeck) and "slot" in other_calling_param: if isinstance(resource, OTDeck) and "slot" in other_calling_param:
resource.assign_child_at_slot(plr_instance, **other_calling_param) resource.assign_child_at_slot(plr_instance, **other_calling_param)
else: else:
_discard_slot = other_calling_param.pop("slot", -1) _discard_slot = other_calling_param.pop("slot", -1)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param) resource.assign_child_resource(
request2.resources = [convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)])] plr_instance,
Coordinate(location["x"], location["y"], location["z"]),
**other_calling_param,
)
request2.resources = [
convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)])
]
rclient2.call(request2) rclient2.call(request2)
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group self,
SendCmd,
"/devices/resource_mesh_manager/add_resource_mesh",
callback_group=self.callback_group,
) )
goal = SendCmd.Goal() goal = SendCmd.Goal()
goal.command = json.dumps({ goal.command = json.dumps(
{
"resources": resources, "resources": resources,
"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, goal_uuid=uuid.uuid4())
def done_cb(*args): def done_cb(*args):
@@ -401,10 +445,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# noinspection PyTypeChecker # noinspection PyTypeChecker
self._service_server: Dict[str, Service] = { self._service_server: Dict[str, Service] = {
"query_host_name": self.create_service( "query_host_name": self.create_service(
SerialCommand, f"/srv{self.namespace}/query_host_name", query_host_name_cb, callback_group=self.callback_group SerialCommand,
f"/srv{self.namespace}/query_host_name",
query_host_name_cb,
callback_group=self.callback_group,
), ),
"append_resource": self.create_service( "append_resource": self.create_service(
SerialCommand, f"/srv{self.namespace}/append_resource", append_resource, callback_group=self.callback_group SerialCommand,
f"/srv{self.namespace}/append_resource",
append_resource,
callback_group=self.callback_group,
), ),
} }
@@ -433,6 +483,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
registered_devices[self.device_id] = device_info registered_devices[self.device_id] = device_info
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.ros.nodes.presets.host_node import HostNode from unilabos.ros.nodes.presets.host_node import HostNode
if not BasicConfig.is_host_mode: if not BasicConfig.is_host_mode:
sclient = self.create_client(SerialCommand, "/node_info_update") sclient = self.create_client(SerialCommand, "/node_info_update")
# 启动线程执行发送任务 # 启动线程执行发送任务
@@ -440,7 +491,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
target=self.send_slave_node_info, target=self.send_slave_node_info,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_send_slave_node_info" name=f"ROSDevice{self.device_id}_send_slave_node_info",
).start() ).start()
else: else:
host_node = HostNode.get_instance(0) host_node = HostNode.get_instance(0)
@@ -451,12 +502,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
sclient.wait_for_service() sclient.wait_for_service()
request = SerialCommand.Request() request = SerialCommand.Request()
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
request.command = json.dumps({
request.command = json.dumps(
{
"SYNC_SLAVE_NODE_INFO": { "SYNC_SLAVE_NODE_INFO": {
"machine_name": BasicConfig.machine_name, "machine_name": BasicConfig.machine_name,
"type": "slave", "type": "slave",
"edge_device_id": self.device_id "edge_device_id": self.device_id,
}}, ensure_ascii=False, cls=TypeEncoder) }
},
ensure_ascii=False,
cls=TypeEncoder,
)
# 发送异步请求并等待结果 # 发送异步请求并等待结果
future = sclient.call_async(request) future = sclient.call_async(request)
@@ -529,6 +586,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
"""创建动作执行回调函数""" """创建动作执行回调函数"""
async def execute_callback(goal_handle: ServerGoalHandle): async def execute_callback(goal_handle: ServerGoalHandle):
# 初始化结果信息变量
execution_error = ""
execution_success = False
action_return_value = None
self.lab_logger().info(f"执行动作: {action_name}") self.lab_logger().info(f"执行动作: {action_name}")
goal = goal_handle.request goal = goal_handle.request
@@ -568,7 +630,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
current_resources.extend(response.resources) current_resources.extend(response.resources)
else: else:
r = ResourceGet.Request() r = ResourceGet.Request()
r.id = action_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else action_kwargs[k][0]["id"] r.id = (
action_kwargs[k]["id"]
if v == "unilabos_msgs/Resource"
else action_kwargs[k][0]["id"]
)
r.with_children = True r.with_children = True
response = await self._resource_clients["resource_get"].call_async(r) response = await self._resource_clients["resource_get"].call_async(r)
current_resources.extend(response.resources) current_resources.extend(response.resources)
@@ -591,7 +657,19 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if asyncio.iscoroutinefunction(ACTION): if asyncio.iscoroutinefunction(ACTION):
try: try:
self.lab_logger().info(f"异步执行动作 {ACTION}") self.lab_logger().info(f"异步执行动作 {ACTION}")
future = ROS2DeviceNode.run_async_func(ACTION, **action_kwargs) future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
def _handle_future_exception(fut):
nonlocal execution_error, execution_success, action_return_value
try:
action_return_value = fut.result()
execution_success = True
except Exception as e:
execution_error = traceback.format_exc()
error(f"异步任务 {ACTION.__name__} 报错了")
error(traceback.format_exc())
future.add_done_callback(_handle_future_exception)
except Exception as e: except Exception as e:
self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}") self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}")
raise e raise e
@@ -600,9 +678,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
future = self._executor.submit(ACTION, **action_kwargs) future = self._executor.submit(ACTION, **action_kwargs)
def _handle_future_exception(fut): def _handle_future_exception(fut):
nonlocal execution_error, execution_success, action_return_value
try: try:
fut.result() action_return_value = fut.result()
execution_success = True
except Exception as e: except Exception as e:
execution_error = traceback.format_exc()
error(f"同步任务 {ACTION.__name__} 报错了") error(f"同步任务 {ACTION.__name__} 报错了")
error(traceback.format_exc()) error(traceback.format_exc())
@@ -693,6 +774,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for attr_name in result_msg_types.keys(): for attr_name in result_msg_types.keys():
if attr_name in ["success", "reached_goal"]: if attr_name in ["success", "reached_goal"]:
setattr(result_msg, attr_name, True) setattr(result_msg, attr_name, True)
elif attr_name == "return_info":
setattr(result_msg, attr_name, serialize_result_info(execution_error, execution_success, action_return_value))
self.lab_logger().info(f"动作 {action_name} 完成并返回结果") self.lab_logger().info(f"动作 {action_name} 完成并返回结果")
return result_msg return result_msg
@@ -738,8 +821,8 @@ class ROS2DeviceNode:
return cls._loop return cls._loop
@classmethod @classmethod
def run_async_func(cls, func, **kwargs): def run_async_func(cls, func, trace_error=True, **kwargs):
return run_async_func(func, loop=cls._loop, **kwargs) return run_async_func(func, loop=cls._loop, trace_error=trace_error, **kwargs)
@property @property
def driver_instance(self): def driver_instance(self):
@@ -791,7 +874,11 @@ class ROS2DeviceNode:
self.resource_tracker = DeviceNodeResourceTracker() self.resource_tracker = DeviceNodeResourceTracker()
# use_pylabrobot_creator 使用 cls的包路径检测 # use_pylabrobot_creator 使用 cls的包路径检测
use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "LiquidHandlerAbstract" use_pylabrobot_creator = (
driver_class.__module__.startswith("pylabrobot")
or driver_class.__name__ == "LiquidHandlerAbstract"
or driver_class.__name__ == "LiquidHandlerBiomek"
)
# TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建 # TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建
# 创建设备类实例 # 创建设备类实例

View File

@@ -151,7 +151,7 @@ class HostNode(BaseROS2DeviceNode):
mqtt_client.publish_registry(device_info["id"], device_info) mqtt_client.publish_registry(device_info["id"], device_info)
for resource_info in lab_registry.obtain_registry_resource_info(): for resource_info in lab_registry.obtain_registry_resource_info():
mqtt_client.publish_registry(resource_info["id"], resource_info) mqtt_client.publish_registry(resource_info["id"], resource_info)
time.sleep(1) # 等待MQTT连接稳定
# 首次发现网络中的设备 # 首次发现网络中的设备
self._discover_devices() self._discover_devices()
@@ -203,8 +203,12 @@ class HostNode(BaseROS2DeviceNode):
try: try:
for bridge in self.bridges: for bridge in self.bridges:
if hasattr(bridge, "resource_add"): if hasattr(bridge, "resource_add"):
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.") resource_start_time = time.time()
resource_add_res = bridge.resource_add(add_schema(resource_with_parent_name)) resource_add_res = bridge.resource_add(add_schema(resource_with_parent_name), True)
resource_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_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())
@@ -610,13 +614,21 @@ class HostNode(BaseROS2DeviceNode):
"""获取结果回调""" """获取结果回调"""
result_msg = future.result().result result_msg = future.result().result
result_data = convert_from_ros_msg(result_msg) result_data = convert_from_ros_msg(result_msg)
status = "success"
try:
ret = json.loads(result_data.get("return_info", "{}")) # 确保返回信息是有效的JSON
suc = ret.get("suc", False)
if not suc:
status = "failed"
except json.JSONDecodeError:
status = "failed"
self.lab_logger().info(f"[Host Node] Result for {action_id} ({uuid_str}): success") self.lab_logger().info(f"[Host Node] Result for {action_id} ({uuid_str}): success")
self.lab_logger().debug(f"[Host Node] Result data: {result_data}") self.lab_logger().debug(f"[Host Node] Result data: {result_data}")
if uuid_str: if uuid_str:
for bridge in self.bridges: for bridge in self.bridges:
if hasattr(bridge, "publish_job_status"): if hasattr(bridge, "publish_job_status"):
bridge.publish_job_status(result_data, uuid_str, "success") bridge.publish_job_status(result_data, uuid_str, status, result_data.get("return_info", "{}"))
def cancel_goal(self, goal_uuid: str) -> None: def cancel_goal(self, goal_uuid: str) -> None:
"""取消目标""" """取消目标"""
@@ -856,7 +868,6 @@ class HostNode(BaseROS2DeviceNode):
测试网络延迟的action实现 测试网络延迟的action实现
通过5次ping-pong机制校对时间误差并计算实际延迟 通过5次ping-pong机制校对时间误差并计算实际延迟
""" """
import time
import uuid as uuid_module import uuid as uuid_module
self.lab_logger().info("=" * 60) self.lab_logger().info("=" * 60)

View File

@@ -5,7 +5,7 @@ from asyncio import get_event_loop
from unilabos.utils.log import error from unilabos.utils.log import error
def run_async_func(func, *, loop=None, **kwargs): def run_async_func(func, *, loop=None, trace_error=True, **kwargs):
if loop is None: if loop is None:
loop = get_event_loop() loop = get_event_loop()
@@ -17,5 +17,6 @@ def run_async_func(func, *, loop=None, **kwargs):
error(traceback.format_exc()) error(traceback.format_exc())
future = asyncio.run_coroutine_threadsafe(func(**kwargs), loop) future = asyncio.run_coroutine_threadsafe(func(**kwargs), loop)
if trace_error:
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
return future return future

View File

@@ -1,4 +1,4 @@
import collections import collections.abc
import json import json
from typing import get_origin, get_args from typing import get_origin, get_args
@@ -21,3 +21,46 @@ class TypeEncoder(json.JSONEncoder):
return str(obj)[8:-2] return str(obj)[8:-2]
return super().default(obj) return super().default(obj)
class ResultInfoEncoder(json.JSONEncoder):
"""专门用于处理任务执行结果信息的JSON编码器"""
def default(self, obj):
# 优先处理类型对象
if isinstance(obj, type):
return str(obj)[8:-2]
# 对于无法序列化的对象,统一转换为字符串
try:
# 尝试调用 __dict__ 或者其他序列化方法
if hasattr(obj, "__dict__"):
return obj.__dict__
elif hasattr(obj, "_asdict"): # namedtuple
return obj._asdict()
elif hasattr(obj, "to_dict"):
return obj.to_dict()
elif hasattr(obj, "dict"):
return obj.dict()
else:
# 如果都不行,转换为字符串
return str(obj)
except Exception:
# 如果转换失败,直接返回字符串表示
return str(obj)
def serialize_result_info(error: str, suc: bool, return_value=None) -> str:
"""
序列化任务执行结果信息
Args:
error: 错误信息字符串
suc: 是否成功的布尔值
return_value: 返回值,可以是任何类型
Returns:
JSON字符串格式的结果信息
"""
result_info = {"error": error, "suc": suc, "return_value": return_value}
return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder)

View File

@@ -29,6 +29,8 @@ set(action_files
"action/HeatChillStart.action" "action/HeatChillStart.action"
"action/HeatChillStop.action" "action/HeatChillStop.action"
"action/LiquidHandlerProtocolCreation.action"
"action/LiquidHandlerAspirate.action" "action/LiquidHandlerAspirate.action"
"action/LiquidHandlerDiscardTips.action" "action/LiquidHandlerDiscardTips.action"
"action/LiquidHandlerDispense.action" "action/LiquidHandlerDispense.action"
@@ -44,6 +46,11 @@ set(action_files
"action/LiquidHandlerStamp.action" "action/LiquidHandlerStamp.action"
"action/LiquidHandlerTransfer.action" "action/LiquidHandlerTransfer.action"
"action/LiquidHandlerTransferBiomek.action"
"action/LiquidHandlerIncubateBiomek.action"
"action/LiquidHandlerMoveBiomek.action"
"action/LiquidHandlerOscillateBiomek.action"
"action/LiquidHandlerAdd.action" "action/LiquidHandlerAdd.action"
"action/LiquidHandlerMix.action" "action/LiquidHandlerMix.action"
"action/LiquidHandlerMoveTo.action" "action/LiquidHandlerMoveTo.action"

View File

@@ -4,6 +4,7 @@ string from_repo_position
Resource to_repo Resource to_repo
string to_repo_position string to_repo_position
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -5,6 +5,7 @@ float64 volume # Optional. Volume of solvent to clean vessel with.
float64 temp # Optional. Temperature to heat vessel to while cleaning. float64 temp # Optional. Temperature to heat vessel to while cleaning.
int32 repeats # Optional. Number of cleaning cycles to perform. int32 repeats # Optional. Number of cleaning cycles to perform.
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -1,4 +1,4 @@
--- ---
string return_info
--- ---

View File

@@ -3,6 +3,7 @@ string vessel
string gas string gas
int32 repeats int32 repeats
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -5,6 +5,7 @@ float64 temp
float64 time float64 time
float64 stir_speed float64 stir_speed
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -1,4 +1,5 @@
float64 float_in float64 float_in
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -6,6 +6,7 @@ bool stir
float64 stir_speed float64 stir_speed
string purpose string purpose
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -3,6 +3,7 @@ string vessel
float64 temp float64 temp
string purpose string purpose
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -1,6 +1,7 @@
# Organic # Organic
string vessel string vessel
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -1,4 +1,5 @@
int32 int_input int32 int_input
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -15,6 +15,7 @@ int32 mix_rate
float64 mix_liquid_height float64 mix_liquid_height
string[] none_keys string[] none_keys
--- ---
string return_info
bool success bool success
--- ---
# 反馈 # 反馈

View File

@@ -7,5 +7,6 @@ float64[] liquid_height
float64[] blow_out_air_volume float64[] blow_out_air_volume
string spread string spread
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -3,6 +3,7 @@
int32[] use_channels int32[] use_channels
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -8,6 +8,7 @@ int32[] blow_out_air_volume
string spread string spread
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -6,6 +6,7 @@ geometry_msgs/Point[] offsets
bool allow_nonzero_volume bool allow_nonzero_volume
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -5,6 +5,7 @@ geometry_msgs/Point offset
bool allow_nonzero_volume bool allow_nonzero_volume
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -0,0 +1,6 @@
int32 time
---
string return_info
bool success
---

View File

@@ -6,6 +6,7 @@ geometry_msgs/Point[] offsets
float64 mix_rate float64 mix_rate
string[] none_keys string[] none_keys
--- ---
string return_info
bool success bool success
--- ---
# 反馈 # 反馈

View File

@@ -0,0 +1,7 @@
string source
string target
---
string return_info
bool success
---

View File

@@ -12,6 +12,7 @@ string put_direction
float64 pickup_distance_from_top float64 pickup_distance_from_top
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -13,6 +13,7 @@ string put_direction
float64 pickup_distance_from_top float64 pickup_distance_from_top
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -12,6 +12,7 @@ string get_direction
string put_direction string put_direction
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -2,6 +2,7 @@ Resource well
float64 dis_to_top float64 dis_to_top
int32 channel int32 channel
--- ---
string return_info
bool success bool success
--- ---
# 反馈 # 反馈

View File

@@ -0,0 +1,7 @@
int32 rpm
int32 time
---
string return_info
bool success
---

View File

@@ -5,6 +5,7 @@ int32[] use_channels
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -4,6 +4,7 @@ Resource tip_rack
geometry_msgs/Point offset geometry_msgs/Point offset
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -0,0 +1,10 @@
string protocol_name
string protocol_description
string protocol_version
string protocol_author
string protocol_date
string protocol_type
string[] none_keys
---
string return_info
---

View File

@@ -12,6 +12,7 @@ bool is_96_well
float64[] top float64[] top
string[] none_keys string[] none_keys
--- ---
string return_info
bool success bool success
--- ---
# 反馈 # 反馈

View File

@@ -4,6 +4,7 @@ int32[] use_channels
bool allow_nonzero_volume bool allow_nonzero_volume
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -3,6 +3,7 @@
bool allow_nonzero_volume bool allow_nonzero_volume
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -7,6 +7,7 @@ float64 aspiration_flow_rate
float64 dispense_flow_rate float64 dispense_flow_rate
--- ---
# 结果字段 # 结果字段
string return_info
bool success bool success
--- ---
# 反馈字段 # 反馈字段

View File

@@ -20,6 +20,7 @@ float64 mix_liquid_height
int32[] delays int32[] delays
string[] none_keys string[] none_keys
--- ---
string return_info
bool success bool success
--- ---
# 反馈 # 反馈

View File

@@ -0,0 +1,11 @@
string source
string target
string tip_rack
float64 volume
string aspirate_technique
string dispense_technique
---
string return_info
bool success
---

View File

@@ -2,5 +2,6 @@ float64 x
float64 y float64 y
float64 z float64 z
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -10,6 +10,7 @@ float64 rinsing_volume
int32 rinsing_repeats int32 rinsing_repeats
bool solid bool solid
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -4,5 +4,6 @@ string[] bind_parent_ids
geometry_msgs/Point[] bind_locations geometry_msgs/Point[] bind_locations
string[] other_calling_params string[] other_calling_params
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -8,5 +8,6 @@ string[] liquid_type
float32[] liquid_volume float32[] liquid_volume
int32 slot_on_deck int32 slot_on_deck
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -1,6 +1,7 @@
# Simple # Simple
string command string command
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -13,6 +13,7 @@ float64 stir_time # Optional. Time stir for after adding solvent, before separat
float64 stir_speed # Optional. Speed to stir at after adding solvent, before separation of phases. float64 stir_speed # Optional. Speed to stir at after adding solvent, before separation of phases.
float64 settling_time # Optional. Time float64 settling_time # Optional. Time
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -2,6 +2,7 @@ int32 powder_tube_number
string target_tube_position string target_tube_position
float64 compound_mass float64 compound_mass
--- ---
string return_info
float64 actual_mass_mg float64 actual_mass_mg
bool success bool success
--- ---

View File

@@ -3,6 +3,7 @@ float64 stir_time
float64 stir_speed float64 stir_speed
float64 settling_time float64 settling_time
--- ---
string return_info
bool success bool success
--- ---
string status string status

View File

@@ -1,4 +1,5 @@
string string string string
--- ---
string return_info
bool success bool success
--- ---

View File

@@ -3,6 +3,7 @@ string wf_name
string params string params
Resource resource Resource resource
--- ---
string return_info
bool success bool success
--- ---
string status string status