Compare commits

...

12 Commits

Author SHA1 Message Date
Junhan Chang
25d46dc9d5 pass the tests 2025-10-11 07:20:34 +08:00
Junhan Chang
88c4d1a9d1 modify bioyond/plr converter, bioyond resource registry, and tests 2025-10-11 04:59:59 +08:00
Xuwznln
81fd8291c5 update todo 2025-10-11 03:38:59 +08:00
Xuwznln
3a11eb90d4 feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了 2025-10-11 03:38:14 +08:00
Xuwznln
387866b9c9 修复同步任务报错不显示的bug 2025-10-11 03:14:12 +08:00
Xuwznln
7f40f141f6 移动内部action以兼容host node 2025-10-11 03:11:17 +08:00
Xuwznln
6fc7ed1b88 过滤本地动作 2025-10-11 03:06:37 +08:00
Xuwznln
93f0e08d75 fix host_node test_resource error 2025-10-11 03:04:15 +08:00
Xuwznln
4b43734b55 fix host_node test_resource error 2025-10-11 02:57:14 +08:00
Xuwznln
174b1914d4 fix host_node error 2025-10-11 02:54:15 +08:00
Xuwznln
704e13f030 新增test_resource动作 2025-10-11 02:53:18 +08:00
Xuwznln
0c42d60cf2 更新transfer_resource_to_another参数,支持spot入参 2025-10-11 02:41:37 +08:00
20 changed files with 1647 additions and 696 deletions

View File

@@ -111,8 +111,8 @@ new_device: # 设备名,要唯一
1.`auto-` 开头的动作:从你 Python 类的方法自动生成 1.`auto-` 开头的动作:从你 Python 类的方法自动生成
2. 通用的驱动动作: 2. 通用的驱动动作:
- `_execute_driver_command`:同步执行驱动命令 - `_execute_driver_command`:同步执行驱动命令(仅本地可用)
- `_execute_driver_command_async`:异步执行驱动命令 - `_execute_driver_command_async`:异步执行驱动命令(仅本地可用)
### 如果要手动定义动作 ### 如果要手动定义动作

View File

@@ -0,0 +1,181 @@
[
{
"id": "3a1c62c4-c3d2-b803-b72d-7f1153ffef3b",
"typeName": "试剂瓶",
"code": "0004-00050",
"barCode": "",
"name": "NMP",
"quantity": 287.16699029126215,
"lockQuantity": 285.16699029126215,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-efce-0939-69ca5a7dfd39",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0008",
"x": 2,
"y": 4,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-1bc1-1296-dae1905c4108",
"typeName": "试剂瓶",
"code": "0004-00052",
"barCode": "",
"name": "NMP",
"quantity": 386.8990291262136,
"lockQuantity": 45.89902912621359,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-f3e7-871a-e470d144296f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0005",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-68a4-bcb3-02fc6ba72d1b",
"typeName": "试剂瓶",
"code": "0004-00053",
"barCode": "",
"name": "NMP",
"quantity": 400.0,
"lockQuantity": 0.0,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0006",
"x": 2,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-d5e0-d850-5439-4499f20f07fe",
"typeName": "分装板",
"code": "0007-00185",
"barCode": "",
"name": "1010",
"quantity": 1.0,
"lockQuantity": 2.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0009",
"x": 3,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdefe-d5e0-28a4-f5d0-f7e2436c575f",
"detailMaterialId": "3a1cdefe-d5e0-94ae-f770-27847e73ad38",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-3ed6-3607-133df89baf5b",
"detailMaterialId": "3a1cdefe-d5e0-f2fa-66bf-94c565d852fb",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-72b6-e015-be7b93cf09eb",
"detailMaterialId": "3a1cdefe-d5e0-81cf-7dad-2e51cab9ffd6",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-81d3-ad30-48134afc9ce7",
"detailMaterialId": "3a1cdefe-d5e0-3fa1-cc72-fda6276ae38d",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-dbdf-d966-9a8926fe1e06",
"detailMaterialId": "3a1cdefe-d5e0-c632-c7da-02d385b18628",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-f099-b260-e3089a2d08c3",
"detailMaterialId": "3a1cdefe-d5e0-561f-73b6-f8501f814dbb",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
}
]
}
]

View File

@@ -0,0 +1,216 @@
[
{
"id": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"typeName": "样品瓶",
"code": "0002-00407",
"barCode": "",
"name": "ODA",
"quantity": 25.0,
"lockQuantity": 2.0,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-7887-9258-e8f8ab7c8a7a",
"typeName": "样品板",
"code": "0008-00160",
"barCode": "",
"name": "1010sample",
"quantity": 1.0,
"lockQuantity": 27.69187,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-4379-affa-9a2935c17f99",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cde21-a4f4-0339-f2b6-8e680ad7e8c7",
"detailMaterialId": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"code": null,
"name": "MPDA",
"quantity": "10.505",
"lockQuantity": "-0.0174",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-a21a-23cf-bb7857b41947",
"detailMaterialId": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"code": null,
"name": "ODA",
"quantity": "1.795",
"lockQuantity": "2.0093",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-af1b-ba0b-2874836800e9",
"detailMaterialId": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"code": null,
"name": "ODA",
"quantity": "25",
"lockQuantity": "2",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"typeName": "样品瓶",
"code": "0002-00406",
"barCode": "",
"name": "ODA",
"quantity": 1.795,
"lockQuantity": 2.00927,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"typeName": "样品瓶",
"code": "0002-00408",
"barCode": "",
"name": "MPDA",
"quantity": 10.505,
"lockQuantity": -0.0174,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92a-08f6-c822-732ab734154c",
"typeName": "样品板",
"code": "0008-00161",
"barCode": "",
"name": "1010sample2",
"quantity": 1.0,
"lockQuantity": 3.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-31f0-8a22-0f98f72260df",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdeff-c92b-3ace-9623-0bcdef6fa07d",
"detailMaterialId": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"code": null,
"name": "BTDA1",
"quantity": "0.362",
"lockQuantity": "14.494",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-856e-f481-792b91b6dbde",
"detailMaterialId": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"code": null,
"name": "BTDA3",
"quantity": "1.935",
"lockQuantity": "13.067",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-d144-c5e5-ab9d94e21187",
"detailMaterialId": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"code": null,
"name": "BTDA2",
"quantity": "1.903",
"lockQuantity": "13.035",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"typeName": "样品瓶",
"code": "0002-00411",
"barCode": "",
"name": "BTDA3",
"quantity": 1.935,
"lockQuantity": 13.067,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"typeName": "样品瓶",
"code": "0002-00410",
"barCode": "",
"name": "BTDA2",
"quantity": 1.903,
"lockQuantity": 13.035,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"typeName": "样品瓶",
"code": "0002-00409",
"barCode": "",
"name": "BTDA1",
"quantity": 0.362,
"lockQuantity": 14.494,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
}
]

View File

@@ -1,5 +1,4 @@
{ [
"data": [
{ {
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9", "id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
"typeName": "烧杯", "typeName": "烧杯",
@@ -191,8 +190,4 @@
} }
] ]
} }
], ]
"code": 1,
"message": "",
"timestamp": 1758560573511
}

View File

@@ -2,6 +2,7 @@ import pytest
import json import json
import os import os
from pylabrobot.resources import Resource as ResourcePLR
from unilabos.resources.graphio import resource_bioyond_to_plr from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
@@ -13,23 +14,63 @@ lab_registry.setup()
type_mapping = { type_mapping = {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier", "烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier", "试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier", "样品板": "BIOYOND_PolymerStation_6StockCarrier",
"分装板": "BIOYOND_PolymerStation_6VialCarrier",
"样品瓶": "BIOYOND_PolymerStation_Solid_Stock",
"90%分装小瓶": "BIOYOND_PolymerStation_Solid_Vial",
"10%分装小瓶": "BIOYOND_PolymerStation_Liquid_Vial",
} }
type_uuid_mapping = {
"烧杯": "",
"试剂瓶": "",
"样品板": "",
"分装板": "3a14196e-5dfe-6e21-0c79-fe2036d052c4",
"样品瓶": "3a14196a-cf7d-8aea-48d8-b9662c7dba94",
"90%分装小瓶": "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea",
"10%分装小瓶": "3a14196c-76be-2279-4e22-7310d69aed68",
}
@pytest.fixture @pytest.fixture
def bioyond_materials() -> list[dict]: def bioyond_materials_reaction() -> list[dict]:
print("加载 BioYond 物料数据...") print("加载 BioYond 物料数据...")
print(os.getcwd()) print(os.getcwd())
with open("bioyond_materials.json", "r", encoding="utf-8") as f: with open("bioyond_materials_reaction.json", "r", encoding="utf-8") as f:
data = json.load(f)["data"] data = json.load(f)
print(f"加载了 {len(data)} 条物料数据") print(f"加载了 {len(data)} 条物料数据")
return data return data
def test_bioyond_to_plr(bioyond_materials) -> list[dict]: @pytest.fixture
def bioyond_materials_liquidhandling_1() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_1.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_2() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_2.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.mark.parametrize("materials_fixture", [
"bioyond_materials_reaction",
"bioyond_materials_liquidhandling_1",
])
def test_bioyond_to_plr(materials_fixture, request) -> list[dict]:
materials = request.getfixturevalue(materials_fixture)
deck = BIOYOND_PolymerReactionStation_Deck("test_deck") deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
print("将 BioYond 物料数据转换为 PLR 格式...") output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
output = resource_bioyond_to_plr(bioyond_materials, type_mapping=type_mapping, deck=deck)
print(deck.summary()) print(deck.summary())
print([resource.serialize() for resource in output]) print([resource.serialize() for resource in output])
print([resource.serialize_all_state() for resource in output]) print([resource.serialize_all_state() for resource in output])
json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)

View File

@@ -45,7 +45,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
for i, arg in enumerate(sys.argv): for i, arg in enumerate(sys.argv):
for option_string in option_strings: for option_string in option_strings:
if arg.startswith(option_string): if arg.startswith(option_string):
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :] new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
sys.argv[i] = new_arg sys.argv[i] = new_arg
break break

View File

@@ -155,11 +155,12 @@ class BioyondWorkstation(WorkstationBase):
"resources": [self.deck] "resources": [self.deck]
}) })
def transfer_resource_to_another(self, resource: ResourceSlot, mount_device_id: DeviceSlot, mount_resource: ResourceSlot): def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot):
ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{
"plr_resources": [resource], "plr_resources": resource,
"target_device_id": mount_device_id, "target_device_id": mount_device_id,
"target_resource_uuid": getattr(mount_resource, "unilabos_uuid", None), "target_resources": mount_resource,
"sites": sites,
}) })
def _configure_station_type(self, station_config: Optional[Dict[str, Any]] = None) -> None: def _configure_station_type(self, station_config: Optional[Dict[str, Any]] = None) -> None:

View File

@@ -889,6 +889,7 @@ dispensing_station.bioyond:
mount_device_id: null mount_device_id: null
mount_resource: null mount_resource: null
resource: null resource: null
sites: null
handles: {} handles: {}
placeholder_keys: placeholder_keys:
mount_device_id: unilabos_devices mount_device_id: unilabos_devices
@@ -904,155 +905,164 @@ dispensing_station.bioyond:
mount_device_id: mount_device_id:
type: object type: object
mount_resource: mount_resource:
properties: items:
category: properties:
type: string category:
children:
items:
type: string type: string
type: array children:
config: items:
type: string type: string
data: type: array
type: string config:
id: type: string
type: string data:
name: type: string
type: string id:
parent: type: string
type: string name:
pose: type: string
properties: parent:
orientation: type: string
properties: pose:
w: properties:
type: number orientation:
x: properties:
type: number w:
y: type: number
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
- w - x
title: orientation - y
type: object - z
position: - w
properties: title: orientation
x: type: object
type: number position:
y: properties:
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
title: position - x
type: object - y
required: - z
- position title: position
- orientation type: object
title: pose required:
type: object - position
sample_id: - orientation
type: string title: pose
type: type: object
type: string sample_id:
required: type: string
- id type:
- name type: string
- sample_id required:
- children - id
- parent - name
- type - sample_id
- category - children
- pose - parent
- config - type
- data - category
title: mount_resource - pose
type: object - config
- data
title: mount_resource
type: object
type: array
resource: resource:
properties: items:
category: properties:
type: string category:
children:
items:
type: string type: string
type: array children:
config: items:
type: string type: string
data: type: array
type: string config:
id: type: string
type: string data:
name: type: string
type: string id:
parent: type: string
type: string name:
pose: type: string
properties: parent:
orientation: type: string
properties: pose:
w: properties:
type: number orientation:
x: properties:
type: number w:
y: type: number
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
- w - x
title: orientation - y
type: object - z
position: - w
properties: title: orientation
x: type: object
type: number position:
y: properties:
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
title: position - x
type: object - y
required: - z
- position title: position
- orientation type: object
title: pose required:
type: object - position
sample_id: - orientation
type: string title: pose
type: type: object
type: string sample_id:
required: type: string
- id type:
- name type: string
- sample_id required:
- children - id
- parent - name
- type - sample_id
- category - children
- pose - parent
- config - type
- data - category
title: resource - pose
type: object - config
- data
title: resource
type: object
type: array
sites:
items:
type: string
type: array
required: required:
- resource - resource
- mount_device_id
- mount_resource - mount_resource
- sites
- mount_device_id
type: object type: object
result: {} result: {}
required: required:

View File

@@ -883,6 +883,7 @@ reaction_station.bioyond:
mount_device_id: null mount_device_id: null
mount_resource: null mount_resource: null
resource: null resource: null
sites: null
handles: {} handles: {}
placeholder_keys: placeholder_keys:
mount_device_id: unilabos_devices mount_device_id: unilabos_devices
@@ -898,155 +899,164 @@ reaction_station.bioyond:
mount_device_id: mount_device_id:
type: object type: object
mount_resource: mount_resource:
properties: items:
category: properties:
type: string category:
children:
items:
type: string type: string
type: array children:
config: items:
type: string type: string
data: type: array
type: string config:
id: type: string
type: string data:
name: type: string
type: string id:
parent: type: string
type: string name:
pose: type: string
properties: parent:
orientation: type: string
properties: pose:
w: properties:
type: number orientation:
x: properties:
type: number w:
y: type: number
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
- w - x
title: orientation - y
type: object - z
position: - w
properties: title: orientation
x: type: object
type: number position:
y: properties:
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
title: position - x
type: object - y
required: - z
- position title: position
- orientation type: object
title: pose required:
type: object - position
sample_id: - orientation
type: string title: pose
type: type: object
type: string sample_id:
required: type: string
- id type:
- name type: string
- sample_id required:
- children - id
- parent - name
- type - sample_id
- category - children
- pose - parent
- config - type
- data - category
title: mount_resource - pose
type: object - config
- data
title: mount_resource
type: object
type: array
resource: resource:
properties: items:
category: properties:
type: string category:
children:
items:
type: string type: string
type: array children:
config: items:
type: string type: string
data: type: array
type: string config:
id: type: string
type: string data:
name: type: string
type: string id:
parent: type: string
type: string name:
pose: type: string
properties: parent:
orientation: type: string
properties: pose:
w: properties:
type: number orientation:
x: properties:
type: number w:
y: type: number
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
- w - x
title: orientation - y
type: object - z
position: - w
properties: title: orientation
x: type: object
type: number position:
y: properties:
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
title: position - x
type: object - y
required: - z
- position title: position
- orientation type: object
title: pose required:
type: object - position
sample_id: - orientation
type: string title: pose
type: type: object
type: string sample_id:
required: type: string
- id type:
- name type: string
- sample_id required:
- children - id
- parent - name
- type - sample_id
- category - children
- pose - parent
- config - type
- data - category
title: resource - pose
type: object - config
- data
title: resource
type: object
type: array
sites:
items:
type: string
type: array
required: required:
- resource - resource
- mount_device_id
- mount_resource - mount_resource
- sites
- mount_device_id
type: object type: object
result: {} result: {}
required: required:

View File

@@ -7134,6 +7134,7 @@ workstation.bioyond:
mount_device_id: null mount_device_id: null
mount_resource: null mount_resource: null
resource: null resource: null
sites: null
handles: {} handles: {}
placeholder_keys: placeholder_keys:
mount_device_id: unilabos_devices mount_device_id: unilabos_devices
@@ -7149,155 +7150,164 @@ workstation.bioyond:
mount_device_id: mount_device_id:
type: object type: object
mount_resource: mount_resource:
properties: items:
category: properties:
type: string category:
children:
items:
type: string type: string
type: array children:
config: items:
type: string type: string
data: type: array
type: string config:
id: type: string
type: string data:
name: type: string
type: string id:
parent: type: string
type: string name:
pose: type: string
properties: parent:
orientation: type: string
properties: pose:
w: properties:
type: number orientation:
x: properties:
type: number w:
y: type: number
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
- w - x
title: orientation - y
type: object - z
position: - w
properties: title: orientation
x: type: object
type: number position:
y: properties:
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
title: position - x
type: object - y
required: - z
- position title: position
- orientation type: object
title: pose required:
type: object - position
sample_id: - orientation
type: string title: pose
type: type: object
type: string sample_id:
required: type: string
- id type:
- name type: string
- sample_id required:
- children - id
- parent - name
- type - sample_id
- category - children
- pose - parent
- config - type
- data - category
title: mount_resource - pose
type: object - config
- data
title: mount_resource
type: object
type: array
resource: resource:
properties: items:
category: properties:
type: string category:
children:
items:
type: string type: string
type: array children:
config: items:
type: string type: string
data: type: array
type: string config:
id: type: string
type: string data:
name: type: string
type: string id:
parent: type: string
type: string name:
pose: type: string
properties: parent:
orientation: type: string
properties: pose:
w: properties:
type: number orientation:
x: properties:
type: number w:
y: type: number
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
- w - x
title: orientation - y
type: object - z
position: - w
properties: title: orientation
x: type: object
type: number position:
y: properties:
type: number x:
z: type: number
type: number y:
required: type: number
- x z:
- y type: number
- z required:
title: position - x
type: object - y
required: - z
- position title: position
- orientation type: object
title: pose required:
type: object - position
sample_id: - orientation
type: string title: pose
type: type: object
type: string sample_id:
required: type: string
- id type:
- name type: string
- sample_id required:
- children - id
- parent - name
- type - sample_id
- category - children
- pose - parent
- config - type
- data - category
title: resource - pose
type: object - config
- data
title: resource
type: object
type: array
sites:
items:
type: string
type: array
required: required:
- resource - resource
- mount_device_id
- mount_resource - mount_resource
- sites
- mount_device_id
type: object type: object
result: {} result: {}
required: required:

View File

@@ -7,14 +7,17 @@ import importlib
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Union, Tuple from typing import Any, Dict, List, Union, Tuple
import msgcenterpy
import yaml import yaml
from unilabos_msgs.msg import Resource from unilabos_msgs.msg import Resource
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema, String, \ from unilabos.ros.msgs.message_converter import (
ros_message_to_json_schema msg_converter_manager,
ros_action_to_json_schema,
String,
ros_message_to_json_schema,
)
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.decorator import singleton from unilabos.utils.decorator import singleton
from unilabos.utils.import_manager import get_enhanced_class_info, get_class from unilabos.utils.import_manager import get_enhanced_class_info, get_class
@@ -22,6 +25,7 @@ from unilabos.utils.type_check import NoAliasDumper
DEFAULT_PATHS = [Path(__file__).absolute().parent] DEFAULT_PATHS = [Path(__file__).absolute().parent]
class ROSMsgNotFound(Exception): class ROSMsgNotFound(Exception):
pass pass
@@ -51,6 +55,7 @@ class Registry:
"ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource" "ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
) )
self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"") self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
self.StrSingleInput = self._replace_type_with_class("StrSingleInput", "host_node", f"")
self.device_type_registry = {} self.device_type_registry = {}
self.device_module_to_registry = {} self.device_module_to_registry = {}
self.resource_type_registry = {} self.resource_type_registry = {}
@@ -137,13 +142,53 @@ class Registry:
"type": self.EmptyIn, "type": self.EmptyIn,
"goal": {}, "goal": {},
"feedback": {}, "feedback": {},
"result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"}, "result": {},
"schema": ros_action_to_json_schema( "schema": ros_action_to_json_schema(
self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。" self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。"
), ),
"goal_default": {}, "goal_default": {},
"handles": {}, "handles": {},
}, },
"auto-test_resource": {
"type": "UniLabJsonCommand",
"goal": {},
"feedback": {},
"result": {},
"schema": {
"description": "",
"properties": {
"feedback": {},
"goal": {
"properties": {
"resource": ros_message_to_json_schema(Resource, "resource"),
"resources": {
"items": {
"properties": ros_message_to_json_schema(
Resource, "resources"
),
"type": "object",
},
"type": "array",
},
"device": {"type": "string"},
"devices": {"items": {"type": "string"}, "type": "array"},
},
"type": "object",
},
"result": {},
},
"title": "test_resource",
"type": "object",
},
"placeholder_keys": {
"device": "unilabos_devices",
"devices": "unilabos_devices",
"resource": "unilabos_resources",
"resources": "unilabos_resources",
},
"goal_default": {},
"handles": {},
},
}, },
}, },
"version": "1.0.0", "version": "1.0.0",
@@ -157,6 +202,8 @@ class Registry:
} }
} }
) )
# 为host_node添加内置的驱动命令动作
self._add_builtin_actions(self.device_type_registry["host_node"], "host_node")
logger.trace(f"[UniLab Registry] ----------Setup----------") logger.trace(f"[UniLab Registry] ----------Setup----------")
self.registry_paths = [Path(path).absolute() for path in self.registry_paths] self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths): for i, path in enumerate(self.registry_paths):
@@ -431,8 +478,15 @@ class Registry:
param_required = arg_info.get("required", True) param_required = arg_info.get("required", True)
if param_type == "unilabos.registry.placeholder_type:ResourceSlot": if param_type == "unilabos.registry.placeholder_type:ResourceSlot":
schema["properties"][param_name] = ros_message_to_json_schema(Resource, param_name) schema["properties"][param_name] = ros_message_to_json_schema(Resource, param_name)
elif param_type == ("list", "unilabos.registry.placeholder_type:ResourceSlot"):
schema["properties"][param_name] = {
"items": ros_message_to_json_schema(Resource, param_name),
"type": "array",
}
else: else:
schema["properties"][param_name] = self._generate_schema_from_info(param_name, param_type, param_default) schema["properties"][param_name] = self._generate_schema_from_info(
param_name, param_type, param_default
)
if param_required: if param_required:
schema["required"].append(param_name) schema["required"].append(param_name)
@@ -444,6 +498,43 @@ class Registry:
"required": ["goal"], "required": ["goal"],
} }
def _add_builtin_actions(self, device_config: Dict[str, Any], device_id: str):
"""
为设备配置添加内置的执行驱动命令动作
Args:
device_config: 设备配置字典
device_id: 设备ID
"""
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
if "class" not in device_config:
return
if "action_value_mappings" not in device_config["class"]:
device_config["class"]["action_value_mappings"] = {}
for additional_action in ["_execute_driver_command", "_execute_driver_command_async"]:
device_config["class"]["action_value_mappings"][additional_action] = {
"type": self._replace_type_with_class("StrSingleInput", device_id, f"动作 {additional_action}"),
"goal": {"string": "string"},
"feedback": {},
"result": {},
"schema": ros_action_to_json_schema(
self._replace_type_with_class("StrSingleInput", device_id, f"动作 {additional_action}")
),
"goal_default": yaml.safe_load(
io.StringIO(
get_yaml_from_goal_type(
self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
).Goal
)
)
),
"handles": {},
}
def load_device_types(self, path: os.PathLike, complete_registry: bool): def load_device_types(self, path: os.PathLike, complete_registry: bool):
# return # return
abs_path = Path(path).absolute() abs_path = Path(path).absolute()
@@ -505,7 +596,9 @@ class Registry:
status_type = "String" # 替换成ROS的String便于显示 status_type = "String" # 替换成ROS的String便于显示
device_config["class"]["status_types"][status_name] = status_type device_config["class"]["status_types"][status_name] = status_type
try: try:
target_type = self._replace_type_with_class(status_type, device_id, f"状态 {status_name}") target_type = self._replace_type_with_class(
status_type, device_id, f"状态 {status_name}"
)
except ROSMsgNotFound: except ROSMsgNotFound:
continue continue
if target_type in [ if target_type in [
@@ -543,10 +636,22 @@ class Registry:
"goal_default": {i["name"]: i["default"] for i in v["args"]}, "goal_default": {i["name"]: i["default"] for i in v["args"]},
"handles": [], "handles": [],
"placeholder_keys": { "placeholder_keys": {
i["name"]: "unilabos_resources" if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot" else "unilabos_devices" i["name"]: (
"unilabos_resources"
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
or i["type"]
== ("list", "unilabos.registry.placeholder_type:ResourceSlot")
else "unilabos_devices"
)
for i in v["args"] for i in v["args"]
if i.get("type", "") in ["unilabos.registry.placeholder_type:ResourceSlot", "unilabos.registry.placeholder_type:DeviceSlot"] if i.get("type", "")
} in [
"unilabos.registry.placeholder_type:ResourceSlot",
"unilabos.registry.placeholder_type:DeviceSlot",
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
]
},
} }
# 不生成已配置action的动作 # 不生成已配置action的动作
for k, v in enhanced_info["action_methods"].items() for k, v in enhanced_info["action_methods"].items()
@@ -605,30 +710,8 @@ class Registry:
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type] device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
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():
action_config["type"] = action_str_type_mapping[action_config["type"]] action_config["type"] = action_str_type_mapping[action_config["type"]]
for additional_action in ["_execute_driver_command", "_execute_driver_command_async"]: # 添加内置的驱动命令动作
device_config["class"]["action_value_mappings"][additional_action] = { self._add_builtin_actions(device_config, device_id)
"type": self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
),
"goal": {"string": "string"},
"feedback": {},
"result": {},
"schema": ros_action_to_json_schema(
self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
)
),
"goal_default": yaml.safe_load(
io.StringIO(
get_yaml_from_goal_type(
self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
).Goal
)
)
),
"handles": {},
}
device_config["file_path"] = str(file.absolute()).replace("\\", "/") device_config["file_path"] = str(file.absolute()).replace("\\", "/")
device_config["registry_type"] = "device" device_config["registry_type"] = "device"
logger.trace( # type: ignore logger.trace( # type: ignore
@@ -652,7 +735,16 @@ class Registry:
device_info_copy = copy.deepcopy(device_info) device_info_copy = copy.deepcopy(device_info)
if "class" in device_info_copy and "action_value_mappings" in device_info_copy["class"]: if "class" in device_info_copy and "action_value_mappings" in device_info_copy["class"]:
action_mappings = device_info_copy["class"]["action_value_mappings"] action_mappings = device_info_copy["class"]["action_value_mappings"]
for action_name, action_config in action_mappings.items(): # 过滤掉内置的驱动命令动作
builtin_actions = ["_execute_driver_command", "_execute_driver_command_async"]
filtered_action_mappings = {
action_name: action_config
for action_name, action_config in action_mappings.items()
if action_name not in builtin_actions
}
device_info_copy["class"]["action_value_mappings"] = filtered_action_mappings
for action_name, action_config in filtered_action_mappings.items():
if "schema" in action_config and action_config["schema"]: if "schema" in action_config and action_config["schema"]:
schema = action_config["schema"] schema = action_config["schema"]
# 确保schema结构存在 # 确保schema结构存在

View File

@@ -34,3 +34,16 @@ BIOYOND_PolymerStation_6VialCarrier:
init_param_schema: {} init_param_schema: {}
registry_type: resource registry_type: resource
version: 1.0.0 version: 1.0.0
BIOYOND_PolymerStation_6StockCarrier:
category:
- bottle_carriers
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6StockCarrier
type: pylabrobot
description: BIOYOND_PolymerStation_6StockCarrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -0,0 +1,24 @@
BIOYOND_PolymerStation_Solid_Stock:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock
type: pylabrobot
BIOYOND_PolymerStation_Solid_Vial:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial
type: pylabrobot
BIOYOND_PolymerStation_Liquid_Vial:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial
type: pylabrobot
BIOYOND_PolymerStation_Solution_Beaker:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solution_Beaker
type: pylabrobot
BIOYOND_PolymerStation_Reagent_Bottle:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle
type: pylabrobot

View File

@@ -1,7 +1,13 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Solid_Stock,
BIOYOND_PolymerStation_Solid_Vial,
BIOYOND_PolymerStation_Liquid_Vial,
BIOYOND_PolymerStation_Solution_Beaker,
BIOYOND_PolymerStation_Reagent_Bottle
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial # 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
@@ -92,6 +98,57 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
return carrier return carrier
def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 20.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="BIOYOND_PolymerStation_6VialCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
for i in range(6):
carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}")
return carrier
def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局""" """6瓶载架 - 2x3布局"""
@@ -138,8 +195,10 @@ def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier:
carrier.num_items_y = 2 carrier.num_items_y = 2
carrier.num_items_z = 1 carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序 ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
for i in range(6): for i in range(3):
carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{ordering[i]}") carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_solidvial_{ordering[i]}")
for i in range(3, 6):
carrier[i] = BIOYOND_PolymerStation_Liquid_Vial(f"{name}_liquidvial_{ordering[i]}")
return carrier return carrier

View File

@@ -2,12 +2,30 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数 # 工厂函数
def BIOYOND_PolymerStation_Solid_Vial( def BIOYOND_PolymerStation_Solid_Stock(
name: str, name: str,
diameter: float = 20.0, diameter: float = 20.0,
height: float = 100.0, height: float = 100.0,
max_volume: float = 30000.0, # 30mL max_volume: float = 30000.0, # 30mL
barcode: str = None, barcode: str = None,
) -> Bottle:
"""创建粉末瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Solid_Stock",
)
def BIOYOND_PolymerStation_Solid_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle: ) -> Bottle:
"""创建粉末瓶""" """创建粉末瓶"""
return Bottle( return Bottle(
@@ -20,6 +38,24 @@ def BIOYOND_PolymerStation_Solid_Vial(
) )
def BIOYOND_PolymerStation_Liquid_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建滴定液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Liquid_Vial",
)
def BIOYOND_PolymerStation_Solution_Beaker( def BIOYOND_PolymerStation_Solution_Beaker(
name: str, name: str,
diameter: float = 60.0, diameter: float = 60.0,

View File

@@ -9,7 +9,10 @@ from unilabos_msgs.msg import Resource
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
from unilabos.ros.msgs.message_converter import convert_to_ros_msg from unilabos.ros.msgs.message_converter import convert_to_ros_msg
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet from unilabos.ros.nodes.resource_tracker import (
ResourceDictInstance,
ResourceTreeSet,
)
from unilabos.utils.banner_print import print_status from unilabos.utils.banner_print import print_status
try: try:
@@ -51,20 +54,51 @@ def canonicalize_nodes_data(
if child in id2idx: if child in id2idx:
nodes[id2idx[child]]["parent"] = parent nodes[id2idx[child]]["parent"] = parent
# 第三步:打印节点信息(用于调试) # 第三步:使用 ResourceInstanceDictFlatten 标准化每个节点
standardized_instances = []
known_nodes: Dict[str, ResourceDictInstance] = {} # {node_id: ResourceDictInstance}
uuid_to_instance: Dict[str, ResourceDictInstance] = {} # {uuid: ResourceDictInstance}
for node in nodes: for node in nodes:
try: try:
print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info") print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info")
# 使用标准化方法
resource_instance = ResourceDictInstance.get_resource_instance_from_dict(node)
known_nodes[node["id"]] = resource_instance
uuid_to_instance[resource_instance.res_content.uuid] = resource_instance
standardized_instances.append(resource_instance)
except Exception as e: except Exception as e:
print_status(f"Failed to read node {node.get('id', 'unknown')}: {e}", "error") print_status(f"Failed to standardize node {node.get('id', 'unknown')}:\n{traceback.format_exc()}", "error")
continue
# 第四步:使用 from_raw_list 创建 ResourceTreeSet自动处理标准化、parent-children关系 # 第四步:建立 parentchildren 关系
try: for node in nodes:
resource_tree_set = ResourceTreeSet.from_raw_list(nodes) node_id = node["id"]
except Exception as e: if node_id not in known_nodes:
print_status(f"Failed to create ResourceTreeSet:\n{traceback.format_exc()}", "error") continue
raise
current_instance = known_nodes[node_id]
# 优先使用 parent_uuid 进行匹配,如果不存在则使用 parent
parent_uuid = node.get("parent_uuid")
parent_id = node.get("parent")
parent_instance = None
# 优先用 parent_uuid 匹配
if parent_uuid and parent_uuid in uuid_to_instance:
parent_instance = uuid_to_instance[parent_uuid]
# 否则用 parent_id 匹配
elif parent_id and parent_id in known_nodes:
parent_instance = known_nodes[parent_id]
# 设置 parent 引用
if parent_instance:
current_instance.res_content.parent = parent_instance.res_content
# 将当前节点添加到父节点的 children 列表
parent_instance.children.append(current_instance)
# 第五步:创建 ResourceTreeSet
resource_tree_set = ResourceTreeSet.from_nested_list(standardized_instances)
return resource_tree_set return resource_tree_set
@@ -576,10 +610,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict =
+ (detail.get("y", 0) - 1) + (detail.get("y", 0) - 1)
) )
bottle = plr_material[number] bottle = plr_material[number]
if detail["name"] in type_mapping:
# plr_material.unassign_child_resource(bottle)
plr_material.sites[number] = None
plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": type_mapping[detail["name"]]}, resource_type=ResourcePLR
)
else:
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "") bottle.code = detail.get("code", "")
bottle.tracker.liquids = [ else:
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
] bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
plr_materials.append(plr_material) plr_materials.append(plr_material)
@@ -599,6 +645,36 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict =
return plr_materials return plr_materials
def resource_plr_to_bioyond(plr_materials: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]:
bioyond_materials = []
for plr_material in plr_materials:
material = {
"name": plr_material.name,
"typeName": plr_material.__class__.__name__,
"code": plr_material.code,
"quantity": 0,
"detail": [],
"locations": [],
}
if hasattr(plr_material, "capacity") and plr_material.capacity > 1:
for idx in range(plr_material.capacity):
bottle = plr_material[idx]
detail = {
"x": (idx // (plr_material.num_items_x * plr_material.num_items_y)) + 1,
"y": ((idx % (plr_material.num_items_x * plr_material.num_items_y)) // plr_material.num_items_x) + 1,
"z": (idx % plr_material.num_items_x) + 1,
"code": bottle.code if hasattr(bottle, "code") else "",
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
}
material["detail"].append(detail)
material["quantity"] = 1.0
else:
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
material["quantity"] = sum(qty for _, qty in bottle.tracker.liquids) if hasattr(plr_material, "tracker") else 0
bioyond_materials.append(material)
return bioyond_materials
def initialize_resource(resource_config: dict, resource_type: Any = None) -> Union[list[dict], ResourcePLR]: def initialize_resource(resource_config: dict, resource_type: Any = None) -> Union[list[dict], ResourcePLR]:
"""Initializes a resource based on its configuration. """Initializes a resource based on its configuration.

View File

@@ -5,15 +5,19 @@ Automated Liquid Handling Station Resource Classes - Simplified Version
from __future__ import annotations from __future__ import annotations
from typing import Dict, Optional from typing import Dict, List, Optional, TypeVar, Union, Sequence, Tuple
import pylabrobot
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.container import Container
from pylabrobot.resources.resource_holder import ResourceHolder
from pylabrobot.resources import Resource as ResourcePLR from pylabrobot.resources import Resource as ResourcePLR
from pylabrobot.resources import Well, ResourceHolder
from pylabrobot.resources.coordinate import Coordinate
class Bottle(Container): LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
class Bottle(Well):
"""瓶子类 - 简化版,不追踪瓶盖""" """瓶子类 - 简化版,不追踪瓶盖"""
def __init__( def __init__(
@@ -37,6 +41,8 @@ class Bottle(Container):
max_volume=max_volume, max_volume=max_volume,
category=category, category=category,
model=model, model=model,
bottom_type="flat",
cross_section_type="circle"
) )
self.diameter = diameter self.diameter = diameter
self.height = height self.height = height
@@ -50,13 +56,6 @@ class Bottle(Container):
"barcode": self.barcode, "barcode": self.barcode,
} }
from string import ascii_uppercase as LETTERS
from typing import Dict, List, Optional, Type, TypeVar, Union, Sequence, Tuple
import pylabrobot
from pylabrobot.resources.resource_holder import ResourceHolder
T = TypeVar("T", bound=ResourceHolder) T = TypeVar("T", bound=ResourceHolder)
S = TypeVar("S", bound=ResourceHolder) S = TypeVar("S", bound=ResourceHolder)

View File

@@ -1,11 +1,12 @@
import copy import copy
import inspect
import io import io
import json import json
import threading import threading
import time import time
import traceback import traceback
import uuid import uuid
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, Union, TYPE_CHECKING from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import asyncio import asyncio
@@ -24,7 +25,6 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import ( from unilabos.resources.graphio import (
convert_resources_to_type,
resource_ulab_to_plr, resource_ulab_to_plr,
initialize_resources, initialize_resources,
dict_to_tree, dict_to_tree,
@@ -34,7 +34,6 @@ from unilabos.resources.graphio import (
from unilabos.resources.plr_additional_res_reg import register from unilabos.resources.plr_additional_res_reg import register
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_with_mapping, convert_from_ros_msg_with_mapping,
convert_to_ros_msg_with_mapping, convert_to_ros_msg_with_mapping,
) )
@@ -48,11 +47,14 @@ from unilabos_msgs.srv import (
) # type: ignore ) # 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, ResourceTreeSet, ResourceDict, \ from unilabos.ros.nodes.resource_tracker import (
ResourceDictInstance DeviceNodeResourceTracker,
ResourceTreeSet,
)
from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.x.rclpyx import get_event_loop
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
from unilabos.utils.async_util import run_async_func from unilabos.utils.async_util import run_async_func
from unilabos.utils.import_manager import default_manager
from unilabos.utils.log import info, debug, warning, error, critical, logger, trace from unilabos.utils.log import info, debug, warning, error, critical, logger, trace
from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str
@@ -332,7 +334,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 创建资源管理客户端 # 创建资源管理客户端
self._resource_clients: Dict[str, Client] = { self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add"), "resource_add": self.create_client(ResourceAdd, "/resources/add"),
"resource_get": self.create_client(ResourceGet, "/resources/get"), "resource_get": self.create_client(SerialCommand, "/resources/get"),
"resource_delete": self.create_client(ResourceDelete, "/resources/delete"), "resource_delete": self.create_client(ResourceDelete, "/resources/delete"),
"resource_update": self.create_client(ResourceUpdate, "/resources/update"), "resource_update": self.create_client(ResourceUpdate, "/resources/update"),
"resource_list": self.create_client(ResourceList, "/resources/list"), "resource_list": self.create_client(ResourceList, "/resources/list"),
@@ -345,7 +347,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
res.response = "" res.response = ""
return res return res
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
# 物料传输到对应的node节点 # 物料传输到对应的node节点
rclient = self.create_client(ResourceAdd, "/resources/add") rclient = self.create_client(ResourceAdd, "/resources/add")
@@ -578,9 +579,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for i in data: for i in data:
action = i.get("action") # remove, add, update action = i.get("action") # remove, add, update
resources_uuid: List[str] = i.get("data") # 资源数据 resources_uuid: List[str] = i.get("data") # 资源数据
additional_add_params = i.get("additional_add_params", {}) # 额外参数
self.lab_logger().info( self.lab_logger().info(
f"[Resource Tree Update] Processing {action} operation, " f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}"
f"resources count: {len(resources_uuid)}"
) )
tree_set = None tree_set = None
if action in ["add", "update"]: if action in ["add", "update"]:
@@ -606,13 +607,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid) parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
if parent_resource is None: if parent_resource is None:
self.lab_logger().warning( self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在") f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
)
else: else:
try: try:
parent_resource.assign_child_resource(plr_resource, location=None) # 特殊兼容所有plr的物料的assign方法和create_resource append_resource后期同步
additional_params = {}
site = additional_add_params.get("site", None)
spec = inspect.signature(parent_resource.assign_child_resource)
if "spot" in spec.parameters:
additional_params["spot"] = site
parent_resource.assign_child_resource(
plr_resource, location=None, **additional_params
)
except Exception as e: except Exception as e:
self.lab_logger().warning( self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}") f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
)
func = getattr(self.driver_instance, "resource_tree_add", None) func = getattr(self.driver_instance, "resource_tree_add", None)
if callable(func): if callable(func):
func(plr_resources) func(plr_resources)
@@ -623,10 +634,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for plr_resource, tree in zip(plr_resources, tree_set.trees): for plr_resource, tree in zip(plr_resources, tree_set.trees):
states = plr_resource.serialize_all_state() states = plr_resource.serialize_all_state()
original_instance: ResourcePLR = self.resource_tracker.figure_resource( original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False) {"uuid": tree.root_node.res_content.uuid}, try_mode=False
)
original_instance.load_all_state(states) original_instance.load_all_state(states)
self.lab_logger().info( self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())}") f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())}"
)
func = getattr(self.driver_instance, "resource_tree_update", None) func = getattr(self.driver_instance, "resource_tree_update", None)
if callable(func): if callable(func):
@@ -634,8 +647,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
results.append({"success": True, "action": "update"}) results.append({"success": True, "action": "update"})
elif action == "remove": elif action == "remove":
# 移除资源 # 移除资源
plr_resources: List[ResourcePLR] = [self.resource_tracker.uuid_to_resources[i] for plr_resources: List[ResourcePLR] = [
i in resources_uuid] self.resource_tracker.uuid_to_resources[i] for i in resources_uuid
]
func = getattr(self.driver_instance, "resource_tree_remove", None) func = getattr(self.driver_instance, "resource_tree_remove", None)
if callable(func): if callable(func):
func(plr_resources) func(plr_resources)
@@ -666,14 +680,26 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res return res
async def transfer_resource_to_another(self, plr_resources: List["ResourcePLR"], target_device_id, target_resource_uuid: str): async def transfer_resource_to_another(
self,
plr_resources: List["ResourcePLR"],
target_device_id: str,
target_resources: List["ResourcePLR"],
sites: List[str],
):
# 准备工作 # 准备工作
uids = [] uids = []
target_uids = []
for plr_resource in plr_resources: for plr_resource in plr_resources:
uid = getattr(plr_resource, "unilabos_uuid", None) uid = getattr(plr_resource, "unilabos_uuid", None)
if uid is None: if uid is None:
raise ValueError(f"物料{plr_resource}没有unilabos_uuid属性无法转运") raise ValueError(f"来源物料{plr_resource}没有unilabos_uuid属性无法转运")
uids.append(uid) uids.append(uid)
for target_resource in target_resources:
uid = getattr(target_resource, "unilabos_uuid", None)
if uid is None:
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运")
target_uids.append(uid)
srv_address = f"/srv{target_device_id}/s2c_resource_tree" srv_address = f"/srv{target_device_id}/s2c_resource_tree"
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
# 等待服务可用(设置超时) # 等待服务可用(设置超时)
@@ -682,37 +708,48 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError(f"[{self.device_id} Node-Resource] Service {srv_address} not available") raise ValueError(f"[{self.device_id} Node-Resource] Service {srv_address} not available")
# 先从当前节点移除资源 # 先从当前节点移除资源
await self.s2c_resource_tree(SerialCommand_Request(command=json.dumps([{ await self.s2c_resource_tree(
"action": "remove", SerialCommand_Request(
"data": uids # 只移除父节点 command=json.dumps([{"action": "remove", "data": uids}], ensure_ascii=False) # 只移除父节点
}], ensure_ascii=False)), SerialCommand_Response()) ),
SerialCommand_Response(),
)
# 通知云端转运资源 # 通知云端转运资源
tree_set = ResourceTreeSet.from_plr_resources(plr_resources) for plr_resource, target_uid, site in zip(plr_resources, target_uids, sites):
for root_node in tree_set.root_nodes: tree_set = ResourceTreeSet.from_plr_resources([plr_resource])
root_node.res_content.parent = None for root_node in tree_set.root_nodes:
root_node.res_content.parent_uuid = target_resource_uuid root_node.res_content.parent = None
r = SerialCommand.Request() root_node.res_content.parent_uuid = target_uid
r.command = json.dumps({"data": {"data": tree_set.dump()}, "action": "update"}) # 和Update Resource一致 r = SerialCommand.Request()
response: SerialCommand_Response = await self._resource_clients["c2s_update_resource_tree"].call_async(r) # type: ignore r.command = json.dumps({"data": {"data": tree_set.dump()}, "action": "update"}) # 和Update Resource一致
self.lab_logger().info(f"资源云端转运到{target_device_id}结果: {response.response}") response: SerialCommand_Response = await self._resource_clients["c2s_update_resource_tree"].call_async(r) # type: ignore
self.lab_logger().info(f"资源云端转运到{target_device_id}结果: {response.response}")
# 创建请求 # 创建请求
request = SerialCommand.Request() request = SerialCommand.Request()
request.command = json.dumps([{ request.command = json.dumps(
"action": "add", [
"data": tree_set.all_nodes_uuid # 只添加父节点,子节点会自动添加 {
}], ensure_ascii=False) "action": "add",
"data": tree_set.all_nodes_uuid, # 只添加父节点,子节点会自动添加
"additional_add_params": {"site": site},
}
],
ensure_ascii=False,
)
future = sclient.call_async(request) future = sclient.call_async(request)
timeout = 30.0 timeout = 30.0
start_time = time.time() start_time = time.time()
while not future.done(): while not future.done():
if time.time() - start_time > timeout: if time.time() - start_time > timeout:
self.lab_logger().error(f"[{self.device_id} Node-Resource] Timeout waiting for response from {target_device_id}") self.lab_logger().error(
return False f"[{self.device_id} Node-Resource] Timeout waiting for response from {target_device_id}"
time.sleep(0.05) )
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}") return False
time.sleep(0.05)
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
return None return None
def register_device(self): def register_device(self):
@@ -872,46 +909,34 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"{action_name} 查询资源状态: Key: {k} Type: {v}") self.lab_logger().info(f"{action_name} 查询资源状态: Key: {k} Type: {v}")
current_resources: Union[List[Resource], List[List[Resource]]] = []
# TODO: resource后面需要分组
only_one_resource = False
try: try:
if isinstance(action_kwargs[k], list) and len(action_kwargs[k]) > 1: # 统一处理单个或多个资源
for i in action_kwargs[k]: is_sequence = v != "unilabos_msgs/Resource"
r = ResourceGet.Request() resource_inputs = action_kwargs[k] if is_sequence else [action_kwargs[k]]
r.id = i["id"] # splash optional
r.with_children = True # 批量查询资源
response = await self._resource_clients["resource_get"].call_async(r) queried_resources = []
current_resources.append(response.resources) for resource_data in resource_inputs:
else: r = SerialCommand.Request()
only_one_resource = True r.command = json.dumps({"id": resource_data["id"], "with_children": True})
r = ResourceGet.Request() # 发送请求并等待响应
r.id = ( response: SerialCommand_Response = await self._resource_clients[
action_kwargs[k]["id"] "resource_get"
if v == "unilabos_msgs/Resource" ].call_async(r)
else action_kwargs[k][0]["id"] raw_data = json.loads(response.response)
)
r.with_children = True # 转换为 PLR 资源
response = await self._resource_clients["resource_get"].call_async(r) tree_set = ResourceTreeSet.from_raw_list(raw_data)
current_resources.extend(response.resources) plr_resource = tree_set.to_plr_resources()[0]
except Exception: queried_resources.append(plr_resource)
logger.error(f"资源查询失败,默认使用本地资源")
# 删除对response.resources的检查因为它总是存在 self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
type_hint = action_paramtypes[k]
final_type = get_type_class(type_hint) # 通过资源跟踪器获取本地实例
if only_one_resource: final_resources = queried_resources if is_sequence else queried_resources[0]
resources_list: List[Dict[str, Any]] = [convert_from_ros_msg(rs) for rs in current_resources] # type: ignore action_kwargs[k] = self.resource_tracker.figure_resource(final_resources, try_mode=False)
self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源")
final_resource = convert_resources_to_type(resources_list, final_type)
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource并做转换
else:
resources_list: List[List[Dict[str, Any]]] = [[convert_from_ros_msg(rs) for rs in sub_res_list] for sub_res_list in current_resources] # type: ignore
final_resource = [
convert_resources_to_type(sub_res_list, final_type)[0]
for sub_res_list in resources_list
]
try:
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource, try_mode=False)
except Exception as e: except Exception as e:
self.lab_logger().error(f"{action_name} 物料实例获取失败: {e}\n{traceback.format_exc()}") self.lab_logger().error(f"{action_name} 物料实例获取失败: {e}\n{traceback.format_exc()}")
error_skip = True error_skip = True
@@ -939,7 +964,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
error( error(
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
) )
error(traceback.format_exc())
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
except Exception as e: except Exception as e:
@@ -956,6 +980,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
action_return_value = fut.result() action_return_value = fut.result()
execution_success = True execution_success = True
except Exception as e: except Exception as e:
execution_error = traceback.format_exc()
error( error(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
) )
@@ -1064,6 +1089,206 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return execute_callback return execute_callback
def _execute_driver_command(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
)
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(
function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
# 处理 ResourceSlot 类型参数
args_list = default_manager._analyze_method_signature(function)["args"]
for arg in args_list:
arg_name = arg["name"]
arg_type = arg["type"]
# 跳过不在 function_args 中的参数
if arg_name not in function_args:
continue
# 处理单个 ResourceSlot
if arg_type == "unilabos.registry.placeholder_type:ResourceSlot":
resource_data = function_args[arg_name]
if isinstance(resource_data, dict) and "id" in resource_data:
try:
converted_resource = self._convert_resource_sync(resource_data)
function_args[arg_name] = converted_resource
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
# 处理 ResourceSlot 列表
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
resource_slot_type = "unilabos.registry.placeholder_type:ResourceSlot"
if arg_type[0] == "list" and arg_type[1] == resource_slot_type:
resource_list = function_args[arg_name]
if isinstance(resource_list, list):
try:
converted_resources = []
for resource_data in resource_list:
if isinstance(resource_data, dict) and "id" in resource_data:
converted_resource = self._convert_resource_sync(resource_data)
converted_resources.append(converted_resource)
function_args[arg_name] = converted_resources
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
return function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
def _convert_resource_sync(self, resource_data: Dict[str, Any]):
"""同步转换资源数据为实例"""
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
# 同步调用资源查询服务
future = self._resource_clients["resource_get"].call_async(r)
# 等待结果使用while循环每次sleep 0.5秒最多等待5秒
timeout = 30.0
elapsed = 0.0
while not future.done() and elapsed < timeout:
time.sleep(0.05)
elapsed += 0.05
if not future.done():
raise Exception(f"资源查询超时: {resource_data['id']}")
response = future.result()
if response is None:
raise Exception(f"资源查询返回空结果: {resource_data['id']}")
current_resources = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(current_resources)
plr_resource = tree_set.to_plr_resources()[0]
# 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0:
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
return plr_resource
elif len(res) == 1:
return res[0]
else:
raise ValueError(f"资源转换得到多个实例: {res}")
async def _execute_driver_command_async(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
)
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(
function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
assert asyncio.iscoroutinefunction(
function
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
# 处理 ResourceSlot 类型参数
args_list = default_manager._analyze_method_signature(function)["args"]
for arg in args_list:
arg_name = arg["name"]
arg_type = arg["type"]
# 跳过不在 function_args 中的参数
if arg_name not in function_args:
continue
# 处理单个 ResourceSlot
if arg_type == "unilabos.registry.placeholder_type:ResourceSlot":
resource_data = function_args[arg_name]
if isinstance(resource_data, dict) and "id" in resource_data:
try:
converted_resource = await self._convert_resource_async(resource_data)
function_args[arg_name] = converted_resource
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
# 处理 ResourceSlot 列表
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
resource_slot_type = "unilabos.registry.placeholder_type:ResourceSlot"
if arg_type[0] == "list" and arg_type[1] == resource_slot_type:
resource_list = function_args[arg_name]
if isinstance(resource_list, list):
try:
converted_resources = []
for resource_data in resource_list:
if isinstance(resource_data, dict) and "id" in resource_data:
converted_resource = await self._convert_resource_async(resource_data)
converted_resources.append(converted_resource)
function_args[arg_name] = converted_resources
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
return await function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
async def _convert_resource_async(self, resource_data: Dict[str, Any]):
"""异步转换资源数据为实例"""
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
# 异步调用资源查询服务
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
current_resources = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(current_resources)
plr_resource = tree_set.to_plr_resources()[0]
# 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0:
# todo: 后续通过decoration来区分减少warning
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
return plr_resource
elif len(res) == 1:
return res[0]
else:
raise ValueError(f"资源转换得到多个实例: {res}")
# 异步上下文管理方法 # 异步上下文管理方法
async def __aenter__(self): async def __aenter__(self):
"""进入异步上下文""" """进入异步上下文"""
@@ -1230,65 +1455,14 @@ class ROS2DeviceNode:
self._ros_node: BaseROS2DeviceNode self._ros_node: BaseROS2DeviceNode
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}") self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
self.driver_instance._ros_node = self._ros_node # type: ignore self.driver_instance._ros_node = self._ros_node # type: ignore
self.driver_instance._execute_driver_command = self._execute_driver_command # type: ignore self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
self.driver_instance._execute_driver_command_async = self._execute_driver_command_async # type: ignore self.driver_instance._execute_driver_command_async = self._ros_node._execute_driver_command_async # type: ignore
if hasattr(self.driver_instance, "post_init"): if hasattr(self.driver_instance, "post_init"):
try: try:
self.driver_instance.post_init(self._ros_node) # type: ignore self.driver_instance.post_init(self._ros_node) # type: ignore
except Exception as e: except Exception as e:
self._ros_node.lab_logger().error(f"设备后初始化失败: {e}") self._ros_node.lab_logger().error(f"设备后初始化失败: {e}")
def _execute_driver_command(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
)
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(
function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
return function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
async def _execute_driver_command_async(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
)
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(
function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
assert asyncio.iscoroutinefunction(
function
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
return await function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
def _start_loop(self): def _start_loop(self):
def run_event_loop(): def run_event_loop():
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()

View File

@@ -15,7 +15,6 @@ from rclpy.service import Service
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ( from unilabos_msgs.srv import (
ResourceAdd, ResourceAdd,
ResourceGet,
ResourceDelete, ResourceDelete,
ResourceUpdate, ResourceUpdate,
ResourceList, ResourceList,
@@ -44,6 +43,7 @@ from unilabos.ros.nodes.resource_tracker import (
) )
from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.exception import DeviceClassInvalid
from unilabos.utils.type_check import serialize_result_info from unilabos.utils.type_check import serialize_result_info
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
if TYPE_CHECKING: if TYPE_CHECKING:
from unilabos.app.ws_client import QueueItem, WSResourceChatData from unilabos.app.ws_client import QueueItem, WSResourceChatData
@@ -152,6 +152,24 @@ class HostNode(BaseROS2DeviceNode):
"/devices/host_node/test_latency", "/devices/host_node/test_latency",
callback_group=self.callback_group, callback_group=self.callback_group,
), ),
"/devices/host_node/test_resource": ActionClient(
self,
lab_registry.EmptyIn,
"/devices/host_node/test_resource",
callback_group=self.callback_group,
),
"/devices/host_node/_execute_driver_command": ActionClient(
self,
lab_registry.StrSingleInput,
"/devices/host_node/_execute_driver_command",
callback_group=self.callback_group,
),
"/devices/host_node/_execute_driver_command_async": ActionClient(
self,
lab_registry.StrSingleInput,
"/devices/host_node/_execute_driver_command_async",
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例 } # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = ( self._action_value_mappings: Dict[str, Dict] = (
{} {}
@@ -234,7 +252,8 @@ class HostNode(BaseROS2DeviceNode):
) )
# resources_config 通过各个设备的 resource_tracker 进行uuid更新利用uuid_mapping # resources_config 通过各个设备的 resource_tracker 进行uuid更新利用uuid_mapping
# resources_config 的 root node 是 # resources_config 的 root node 是
for node in resources_config.root_nodes: for tree in resources_config.trees:
node = tree.root_node
if node.res_content.type == "device": if node.res_content.type == "device":
for sub_node in node.children: for sub_node in node.children:
# 只有二级子设备 # 只有二级子设备
@@ -245,8 +264,11 @@ class HostNode(BaseROS2DeviceNode):
{"name": sub_node.res_content.name}) {"name": sub_node.res_content.name})
device_tracker.loop_update_uuid(resource_instance, uuid_mapping) device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
else: else:
resource_instance = self.resource_tracker.figure_resource({"name": node.res_content.name}) try:
self._resource_tracker.loop_update_uuid(resource_instance, uuid_mapping) for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
self.resource_tracker.add_resource(plr_resource)
except Exception as ex:
self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!")
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())
@@ -799,7 +821,7 @@ class HostNode(BaseROS2DeviceNode):
ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup() ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup()
), ),
"resource_get": self.create_service( "resource_get": self.create_service(
ResourceGet, "/resources/get", self._resource_get_callback, callback_group=ReentrantCallbackGroup() SerialCommand, "/resources/get", self._resource_get_callback, callback_group=ReentrantCallbackGroup()
), ),
"resource_delete": self.create_service( "resource_delete": self.create_service(
ResourceDelete, ResourceDelete,
@@ -1011,7 +1033,7 @@ class HostNode(BaseROS2DeviceNode):
resources = [convert_to_ros_msg(Resource, resource) for resource in r] resources = [convert_to_ros_msg(Resource, resource) for resource in r]
return resources return resources
def _resource_get_callback(self, request: ResourceGet.Request, response: ResourceGet.Response): def _resource_get_callback(self, request: SerialCommand.Request, response: SerialCommand.Response):
""" """
获取资源回调 获取资源回调
@@ -1025,20 +1047,12 @@ class HostNode(BaseROS2DeviceNode):
响应对象,包含查询到的资源 响应对象,包含查询到的资源
""" """
try: try:
http_req = self.bridges[-1].resource_get(request.id, request.with_children) data = json.loads(request.command)
response.resources = self._resource_get_process(http_req) http_req = self.bridges[-1].resource_get(data["id"], data["with_children"])
response.response = json.dumps(http_req["data"])
return response return response
except Exception as e: except Exception as e:
self.lab_logger().error(f"[Host Node-Resource] Error retrieving from bridge: {str(e)}") self.lab_logger().error(f"[Host Node-Resource] Error retrieving from bridge: {str(e)}")
# 从 ResourceTreeSet 中查找资源
resources_list = (
[node.res_content.model_dump(by_alias=True) for node in self.resources_config.all_nodes]
if self.resources_config
else []
)
r = [resource for resource in resources_list if resource.get("id") == request.id]
self.lab_logger().debug(f"[Host Node-Resource] Retrieved from local: {len(r)} resources")
response.resources = [convert_to_ros_msg(Resource, resource) for resource in r]
return response return response
def _resource_delete_callback(self, request, response): def _resource_delete_callback(self, request, response):
@@ -1240,6 +1254,12 @@ class HostNode(BaseROS2DeviceNode):
"status": "success", "status": "success",
} }
def test_resource(self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]):
return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
"devices": [device, *devices],
}
def handle_pong_response(self, pong_data: dict): def handle_pong_response(self, pong_data: dict):
""" """
处理pong响应 处理pong响应

View File

@@ -29,9 +29,11 @@ from unilabos.utils.type_check import serialize_result_info, get_result_info_str
if TYPE_CHECKING: if TYPE_CHECKING:
from unilabos.devices.workstation.workstation_base import WorkstationBase from unilabos.devices.workstation.workstation_base import WorkstationBase
class ROS2WorkstationNodeTempError(Exception): class ROS2WorkstationNodeTempError(Exception):
pass pass
class ROS2WorkstationNode(BaseROS2DeviceNode): class ROS2WorkstationNode(BaseROS2DeviceNode):
""" """
ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。 ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。
@@ -63,10 +65,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
driver_instance=driver_instance, driver_instance=driver_instance,
device_id=device_id, device_id=device_id,
status_types=status_types, status_types=status_types,
action_value_mappings={ action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
**action_value_mappings,
**self.protocol_action_mappings
},
hardware_interface=hardware_interface, hardware_interface=hardware_interface,
print_publish=print_publish, print_publish=print_publish,
resource_tracker=resource_tracker, resource_tracker=resource_tracker,
@@ -89,7 +88,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
d = self.initialize_device(device_id, device_config) d = self.initialize_device(device_id, device_config)
except Exception as ex: except Exception as ex:
self.lab_logger().error( self.lab_logger().error(
f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}") f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}"
)
d = None d = None
if d is None: if d is None:
continue continue
@@ -109,10 +109,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
if d: if d:
hardware_interface = d.ros_node_instance._hardware_interface hardware_interface = d.ros_node_instance._hardware_interface
if ( if (
hasattr(d.driver_instance, hardware_interface["name"]) hasattr(d.driver_instance, hardware_interface["name"])
and hasattr(d.driver_instance, hardware_interface["write"]) and hasattr(d.driver_instance, hardware_interface["write"])
and ( and (hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"]))
hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"]))
): ):
name = getattr(d.driver_instance, hardware_interface["name"]) name = getattr(d.driver_instance, hardware_interface["name"])
@@ -160,7 +159,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器 node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器
for action_name, action_mapping in node._action_value_mappings.items(): for action_name, action_mapping in node._action_value_mappings.items():
if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith( if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith(
"UniLabJsonCommand"): "UniLabJsonCommand"
):
continue continue
action_id = f"/devices/{device_id_abs}/{action_name}" action_id = f"/devices/{device_id_abs}/{action_name}"
if action_id not in self._action_clients: if action_id not in self._action_clients:
@@ -245,8 +245,10 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
logs.append(step) logs.append(step)
elif isinstance(step, list): elif isinstance(step, list):
logs.append(step) logs.append(step)
self.lab_logger().info(f"Goal received: {protocol_kwargs}, running steps: " self.lab_logger().info(
f"{json.dumps(logs, indent=4, ensure_ascii=False)}") f"Goal received: {protocol_kwargs}, running steps: "
f"{json.dumps(logs, indent=4, ensure_ascii=False)}"
)
time_start = time.time() time_start = time.time()
time_overall = 100 time_overall = 100
@@ -268,7 +270,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
if not ret_info.get("suc", False): if not ret_info.get("suc", False):
raise RuntimeError(f"Step {i + 1} failed.") raise RuntimeError(f"Step {i + 1} failed.")
except ROS2WorkstationNodeTempError as ex: except ROS2WorkstationNodeTempError as ex:
step_results.append({"step": i + 1, "action": action["action_name"], "result": ex.args[0]}) step_results.append(
{"step": i + 1, "action": action["action_name"], "result": ex.args[0]}
)
elif isinstance(action, list): elif isinstance(action, list):
# 如果是并行动作,同时执行 # 如果是并行动作,同时执行
actions = action actions = action
@@ -307,8 +311,12 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
except Exception as e: except Exception as e:
# 捕获并记录错误信息 # 捕获并记录错误信息
str_step_results = [ str_step_results = [
{k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v for k, v in {
i.items()} for i in step_results] k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v
for k, v in i.items()
}
for i in step_results
]
execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}" execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}"
execution_success = False execution_success = False
self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}") self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}")
@@ -381,7 +389,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
"""还没有改过的部分""" """还没有改过的部分"""
def _setup_hardware_proxy( def _setup_hardware_proxy(
self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method
): ):
"""为设备设置硬件接口代理""" """为设备设置硬件接口代理"""
# extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])] # extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])]
@@ -405,17 +413,3 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
if write_method: if write_method:
# bound_write = MethodType(_write, device.driver_instance) # bound_write = MethodType(_write, device.driver_instance)
setattr(device.driver_instance, write_method, _write) setattr(device.driver_instance, write_method, _write)
async def _update_resources(self, goal, protocol_kwargs):
"""更新资源状态"""
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
if protocol_kwargs[k] is not None:
try:
r = ResourceUpdate.Request()
r.resources = [
convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k])
]
await self._resource_clients["resource_update"].call_async(r)
except Exception as e:
self.lab_logger().error(f"更新资源失败: {e}")