HR物料同步,前端展示位置修复 (#135)

* 更新Bioyond工作站配置,添加新的物料类型映射和载架定义,优化物料查询逻辑

* 添加Bioyond实验配置文件,定义物料类型映射和设备配置

* 更新bioyond_warehouse_reagent_stack方法,修正试剂堆栈尺寸和布局描述

* 更新Bioyond实验配置,修正物料类型映射,优化设备配置

* 更新Bioyond资源同步逻辑,优化物料入库流程,增强错误处理和日志记录

* 更新Bioyond资源,添加配液站和反应站专用载架,优化仓库工厂函数的排序方式

* 更新Bioyond资源,添加配液站和反应站相关载架,优化试剂瓶和样品瓶配置

* 更新Bioyond实验配置,修正试剂瓶载架ID,确保与设备匹配

* 更新Bioyond资源,移除反应站单烧杯载架,添加反应站单烧瓶载架分类

* Refactor Bioyond resource synchronization and update bottle carrier definitions

- Removed traceback printing in error handling for Bioyond synchronization.
- Enhanced logging for existing Bioyond material ID usage during synchronization.
- Added new bottle carrier definitions for single flask and updated existing ones.
- Refactored dispensing station and reaction station bottle definitions for clarity and consistency.
- Improved resource mapping and error handling in graphio for Bioyond resource conversion.
- Introduced layout parameter in warehouse factory for better warehouse configuration.

* 更新Bioyond仓库工厂,添加排序方式支持,优化坐标计算逻辑

* 更新Bioyond载架和甲板配置,调整样品板尺寸和仓库坐标

* 更新Bioyond资源同步,增强占用位置日志信息,修正坐标转换逻辑

* 更新Bioyond反应站和分配站配置,调整材料类型映射和ID,移除不必要的项

* support name change during materials change

* fix json dumps

* correct tip

* 优化调度器API路径,更新相关方法描述

* 更新 BIOYOND 载架相关文档,调整 API 以支持自带试剂瓶的载架类型,修复资源获取时的子物料处理逻辑

* 实现资源删除时的同步处理,优化出库操作逻辑

* 修复 ItemizedCarrier 中的可见性逻辑

* 保存 Bioyond 原始信息到 unilabos_extra,以便出库时查询

* 根据 resource.capacity 判断是试剂瓶(载架)还是多瓶载架,走不同的奔曜转换

* Fix bioyond bottle_carriers ordering

* 优化 Bioyond 物料同步逻辑,增强坐标解析和位置更新处理

* disable slave connect websocket

* correct remove_resource stats

* change uuid logger to trace level

* enable slave mode

* refactor(bioyond): 统一资源命名并优化物料同步逻辑

- 将DispensingStation和ReactionStation资源统一为PolymerStation命名
- 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询
- 添加物料默认参数配置功能
- 调整仓库坐标布局
- 清理废弃资源定义

* feat(warehouses): 为仓库函数添加col_offset和layout参数

* refactor: 更新实验配置中的物料类型映射命名

将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性

* fix: 更新实验配置中的载体名称从6VialCarrier到6StockCarrier

* feat(bioyond): 实现物料创建与入库分离逻辑

将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库
简化状态检查接口,仅返回连接状态

* fix(reaction_station): 修正液体进料烧杯体积单位并增强返回结果

将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景
在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息

* feat(dispensing_station): 在任务创建返回结果中添加order_params信息

在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数

* fix(dispensing_station): 修改90%物料分配逻辑从分成3份改为直接使用

原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion

* feat(bioyond): 添加任务编码和任务ID的输出,支持批量任务创建后的状态监控

* refactor(registry): 简化设备配置中的任务结果处理逻辑

将多个单独的任务编码和ID字段合并为统一的return_info字段
更新相关描述以反映新的数据结构

* feat(工作站): 添加HTTP报送服务和任务完成状态跟踪

- 在graphio.py中添加API必需字段
- 实现工作站HTTP服务启动和停止逻辑
- 添加任务完成状态跟踪字典和等待方法
- 重写任务完成报送处理方法记录状态
- 支持批量任务完成等待和报告获取

* refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能

该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构

* fix: 更新任务报告API错误

* fix(workstation_http_service): 修复状态查询中device_id获取逻辑

处理状态查询时安全获取device_id,避免因属性不存在导致的异常

* fix(bioyond_studio): 改进物料入库失败时的错误处理和日志记录

在物料入库API调用失败时,添加更详细的错误信息打印
同时修正station.py中对空响应和失败情况的判断逻辑

* refactor(bioyond): 优化瓶架载体的分配逻辑和注释说明

重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配
添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系

* fix(bioyond_rpc): 修复物料入库成功时无data字段返回空的问题

当API返回成功但无data字段时,返回包含success标识的字典而非空字典

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
Co-authored-by: Junhan Chang <changjh@dp.tech>
This commit is contained in:
ZiWei
2025-11-15 02:57:48 +08:00
committed by Xuwznln
parent 37e0f1037c
commit a625a86e3e
19 changed files with 2578 additions and 585 deletions

View File

@@ -0,0 +1,171 @@
{
"nodes": [
{
"id": "dispensing_station_bioyond",
"name": "dispensing_station_bioyond",
"children": [
"Bioyond_Dispensing_Deck"
],
"parent": null,
"type": "device",
"class": "bioyond_dispensing_station",
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44400",
"material_type_mappings": {
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14196b-24f2-ca49-9081-0cab8021bf1a"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14196b-8bcf-a460-4f74-23f21ca79e72"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"分装板",
"3a14196e-5dfe-6e21-0c79-fe2036d052c4"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14196c-76be-2279-4e22-7310d69aed68"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"
],
"BIOYOND_PolymerStation_8StockCarrier": [
"样品板",
"3a14196e-b7a0-a5da-1931-35f3000281e9"
],
"BIOYOND_PolymerStation_Solid_Stock": [
"样品瓶",
"3a14196a-cf7d-8aea-48d8-b9662c7dba94"
]
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Dispensing_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Dispensing_Deck",
"name": "Bioyond_Dispensing_Deck",
"sample_id": null,
"children": [],
"parent": "dispensing_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerPreparationStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerPreparationStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
},
{
"id": "reaction_station_bioyond",
"name": "reaction_station_bioyond",
"parent": null,
"children": [
"Bioyond_Deck"
],
"type": "device",
"class": "reaction_station.bioyond",
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44402",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"BIOYOND_PolymerStation_Reactor": [
"反应器",
"3a14233b-902d-0d7b-4533-3f60f1c41c1b"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14233b-56e3-6c53-a8ab-fcaac163a9ba"
],
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14233b-f0a9-ba84-eaa9-0d4718b361b6"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"样品板",
"3a142339-80de-8f25-6093-1b1b1b6c322e"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14233a-26e1-28f8-af6a-60ca06ba0165"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14233a-84a3-088d-6676-7cb4acd57c64"
],
"BIOYOND_PolymerStation_TipBox": [
"枪头盒",
"3a143890-9d51-60ac-6d6f-6edb43c12041"
]
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"children": [],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerReactionStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -12,7 +12,37 @@
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388"
"api_host": "http://192.168.1.200:44388",
"material_type_mappings": {
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14196b-24f2-ca49-9081-0cab8021bf1a"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14196b-8bcf-a460-4f74-23f21ca79e72"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"分装板",
"3a14196e-5dfe-6e21-0c79-fe2036d052c4"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14196c-76be-2279-4e22-7310d69aed68"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"
],
"BIOYOND_PolymerStation_8StockCarrier": [
"样品板",
"3a14196e-b7a0-a5da-1931-35f3000281e9"
],
"BIOYOND_PolymerStation_Solid_Stock": [
"样品瓶",
"3a14196a-cf7d-8aea-48d8-b9662c7dba94"
]
}
},
"deck": {
"data": {
@@ -50,4 +80,4 @@
"data": {}
}
]
}
}

View File

@@ -24,41 +24,33 @@
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": [
"BIOYOND_PolymerStation_1FlaskCarrier",
"3a14196b-24f2-ca49-9081-0cab8021bf1a"
"BIOYOND_PolymerStation_Reactor": [
"反应器",
"3a14233b-902d-0d7b-4533-3f60f1c41c1b"
],
"试剂瓶": [
"BIOYOND_PolymerStation_1BottleCarrier",
""
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14233b-56e3-6c53-a8ab-fcaac163a9ba"
],
"样品板": [
"BIOYOND_PolymerStation_6StockCarrier",
"3a14196e-b7a0-a5da-1931-35f3000281e9"
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14233b-f0a9-ba84-eaa9-0d4718b361b6"
],
"分装板": [
"BIOYOND_PolymerStation_6VialCarrier",
"3a14196e-5dfe-6e21-0c79-fe2036d052c4"
"BIOYOND_PolymerStation_6StockCarrier": [
"样品板",
"3a142339-80de-8f25-6093-1b1b1b6c322e"
],
"样品瓶": [
"BIOYOND_PolymerStation_Solid_Stock",
"3a14196a-cf7d-8aea-48d8-b9662c7dba94"
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14233a-26e1-28f8-af6a-60ca06ba0165"
],
"90%分装小瓶": [
"BIOYOND_PolymerStation_Solid_Vial",
"3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14233a-84a3-088d-6676-7cb4acd57c64"
],
"10%分装小瓶": [
"BIOYOND_PolymerStation_Liquid_Vial",
"3a14196c-76be-2279-4e22-7310d69aed68"
],
"枪头盒": [
"BIOYOND_PolymerStation_TipBox",
""
],
"反应器": [
"BIOYOND_PolymerStation_Reactor",
""
"BIOYOND_PolymerStation_TipBox": [
"枪头盒",
"3a143890-9d51-60ac-6d6f-6edb43c12041"
]
}
},
@@ -97,4 +89,4 @@
"data": {}
}
]
}
}

View File

@@ -212,8 +212,14 @@ class BioyondV1RPC(BaseRequest):
})
if not response or response['code'] != 1:
if response:
error_msg = response.get('message', '未知错误')
print(f"[ERROR] 物料入库失败: code={response.get('code')}, message={error_msg}")
else:
print(f"[ERROR] 物料入库失败: API 无响应")
return {}
return response.get("data", {})
# 入库成功时,即使没有 data 字段,也返回成功标识
return response.get("data") or {"success": True}
def delete_material(self, material_id: str) -> dict:
"""
@@ -501,7 +507,7 @@ class BioyondV1RPC(BaseRequest):
return {}
response = self.post(
url=f'{self.host}/api/lims/order/order-report',
url=f'{self.host}/api/lims/order/project-order-report',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -678,7 +684,7 @@ class BioyondV1RPC(BaseRequest):
def scheduler_pause(self) -> int:
"""描述:暂停调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/scheduler-pause',
url=f'{self.host}/api/lims/scheduler/pause',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -689,8 +695,9 @@ class BioyondV1RPC(BaseRequest):
return response.get("code", 0)
def scheduler_continue(self) -> int:
"""描述:继续调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/scheduler-continue',
url=f'{self.host}/api/lims/scheduler/continue',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -703,7 +710,7 @@ class BioyondV1RPC(BaseRequest):
def scheduler_stop(self) -> int:
"""描述:停止调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/scheduler-stop',
url=f'{self.host}/api/lims/scheduler/stop',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -714,9 +721,9 @@ class BioyondV1RPC(BaseRequest):
return response.get("code", 0)
def scheduler_reset(self) -> int:
"""描述:重置调度器"""
"""描述:复位调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/scheduler-reset',
url=f'{self.host}/api/lims/scheduler/reset',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -734,7 +741,7 @@ class BioyondV1RPC(BaseRequest):
print("正在加载材料列表缓存...")
# 加载所有类型的材料:耗材(0)、样品(1)、试剂(2)
material_types = [1, 2]
material_types = [0, 1, 2]
for type_mode in material_types:
print(f"正在加载类型 {type_mode} 的材料...")
@@ -788,4 +795,4 @@ class BioyondV1RPC(BaseRequest):
def get_available_materials(self):
"""获取所有可用的材料名称列表"""
return list(self.material_cache.keys())
return list(self.material_cache.keys())

View File

@@ -1,5 +1,7 @@
from datetime import datetime
import json
import time
from typing import Optional, Dict, Any
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
@@ -23,6 +25,9 @@ class BioyondDispensingStation(BioyondWorkstation):
# self._logger = SimpleLogger()
# self.is_running = False
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
self.order_completion_status = {}
# 90%10%小瓶投料任务创建方法
def create_90_10_vial_feeding_task(self,
order_name: str = None,
@@ -270,7 +275,45 @@ class BioyondDispensingStation(BioyondWorkstation):
# 7. 调用create_order方法创建任务
result = self.hardware_interface.create_order(json_str)
self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}")
return json.dumps({"suc": True})
# 8. 解析结果获取order_id
order_id = None
if isinstance(result, str):
# result 格式: "{'3a1d895c-4d39-d504-1398-18f5a40bac1e': [{'id': '...', ...}]}"
# 第一个键就是order_id (UUID)
try:
# 尝试解析字符串为字典
import ast
result_dict = ast.literal_eval(result)
# 获取第一个键作为order_id
if result_dict and isinstance(result_dict, dict):
first_key = list(result_dict.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}")
else:
self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}")
except Exception as e:
self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}, result类型={type(result)}")
elif isinstance(result, dict):
# 如果已经是字典
if result:
first_key = list(result.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}")
if not order_id:
self.hardware_interface._logger.warning(
f"⚠ 未能提取order_idresult={result[:100] if isinstance(result, str) else result}"
)
# 返回成功结果和构建的JSON数据
return json.dumps({
"suc": True,
"order_code": order_code,
"order_id": order_id,
"result": result,
"order_params": order_data
})
except BioyondException:
# 重新抛出BioyondException
@@ -398,7 +441,37 @@ class BioyondDispensingStation(BioyondWorkstation):
result = self.hardware_interface.create_order(json_str)
self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}")
return json.dumps({"suc": True})
# 8. 解析结果获取order_id
order_id = None
if isinstance(result, str):
try:
import ast
result_dict = ast.literal_eval(result)
if result_dict and isinstance(result_dict, dict):
first_key = list(result_dict.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}")
else:
self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}")
except Exception as e:
self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}")
elif isinstance(result, dict):
if result:
first_key = list(result.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}")
if not order_id:
self.hardware_interface._logger.warning(f"⚠ 未能提取order_id")
# 返回成功结果和构建的JSON数据
return json.dumps({
"suc": True,
"order_code": order_code,
"order_id": order_id,
"result": result,
"order_params": order_data
})
except BioyondException:
# 重新抛出BioyondException
@@ -499,15 +572,24 @@ class BioyondDispensingStation(BioyondWorkstation):
hold_m_name=hold_m_name
)
# 解析返回结果以获取order_code和order_id
result_data = json.loads(result) if isinstance(result, str) else result
order_code = result_data.get("order_code")
order_id = result_data.get("order_id")
order_params = result_data.get("order_params", {})
results.append({
"index": idx + 1,
"name": name,
"success": True,
"hold_m_name": hold_m_name
"order_code": order_code,
"order_id": order_id,
"hold_m_name": hold_m_name,
"order_params": order_params
})
success_count += 1
self.hardware_interface._logger.info(
f"成功创建二胺溶液配置任务: {name}"
f"成功创建二胺溶液配置任务: {name}, order_code={order_code}, order_id={order_id}"
)
except BioyondException as e:
@@ -533,11 +615,17 @@ class BioyondDispensingStation(BioyondWorkstation):
f"创建第 {idx + 1} 个任务时发生未知错误: {str(e)}"
)
# 提取所有成功任务的order_code和order_id
order_codes = [r["order_code"] for r in results if r["success"]]
order_ids = [r["order_id"] for r in results if r["success"]]
# 返回汇总结果
summary = {
"total": len(solutions),
"success": success_count,
"failed": failed_count,
"order_codes": order_codes,
"order_ids": order_ids,
"details": results
}
@@ -546,8 +634,13 @@ class BioyondDispensingStation(BioyondWorkstation):
f"成功={success_count}, 失败={failed_count}"
)
# 返回JSON字符串格式
return json.dumps(summary, ensure_ascii=False)
# 构建返回结果
summary["return_info"] = {
"order_codes": order_codes,
"order_ids": order_ids,
}
return summary
except BioyondException:
raise
@@ -613,22 +706,15 @@ class BioyondDispensingStation(BioyondWorkstation):
if not all([name, main_portion is not None, titration_portion is not None, titration_solvent is not None]):
raise BioyondException("titration 数据缺少必要参数")
# 将main_portion平均分成3份作为90%物料3个小瓶
portion_90 = main_portion / 3
# 调用单个任务创建方法
result = self.create_90_10_vial_feeding_task(
order_name=f"90%10%小瓶投料-{name}",
speed=speed,
temperature=temperature,
delay_time=delay_time,
# 90%物料 - 主称固体平均分成3份
# 90%物料 - 主称固体直接使用main_portion
percent_90_1_assign_material_name=name,
percent_90_1_target_weigh=str(round(portion_90, 6)),
percent_90_2_assign_material_name=name,
percent_90_2_target_weigh=str(round(portion_90, 6)),
percent_90_3_assign_material_name=name,
percent_90_3_target_weigh=str(round(portion_90, 6)),
percent_90_1_target_weigh=str(round(main_portion, 6)),
# 10%物料 - 滴定固体 + 滴定溶剂只使用第1个10%小瓶)
percent_10_1_assign_material_name=name,
percent_10_1_target_weigh=str(round(titration_portion, 6)),
@@ -637,29 +723,54 @@ class BioyondDispensingStation(BioyondWorkstation):
hold_m_name=hold_m_name
)
summary = {
# 解析返回结果以获取order_code和order_id
result_data = json.loads(result) if isinstance(result, str) else result
order_code = result_data.get("order_code")
order_id = result_data.get("order_id")
order_params = result_data.get("order_params", {})
# 构建详细信息(保持原有结构)
detail = {
"index": 1,
"name": name,
"success": True,
"order_code": order_code,
"order_id": order_id,
"hold_m_name": hold_m_name,
"material_name": name,
"90_vials": {
"count": 3,
"weight_per_vial": round(portion_90, 6),
"count": 1,
"weight_per_vial": round(main_portion, 6),
"total_weight": round(main_portion, 6)
},
"10_vials": {
"count": 1,
"solid_weight": round(titration_portion, 6),
"liquid_volume": round(titration_solvent, 6)
}
},
"order_params": order_params
}
# 构建批量结果格式与diamine_solution_tasks保持一致
summary = {
"total": 1,
"success": 1,
"failed": 0,
"order_codes": [order_code],
"order_ids": [order_id],
"details": [detail]
}
self.hardware_interface._logger.info(
f"成功创建90%10%小瓶投料任务: {hold_m_name}, "
f"90%物料={portion_90:.6f}g×3, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL"
f"成功创建90%10%小瓶投料任务: {name}, order_code={order_code}, order_id={order_id}"
)
# 返回JSON字符串格式
return json.dumps(summary, ensure_ascii=False)
# 构建返回结果
summary["return_info"] = {
"order_codes": [order_code],
"order_ids": [order_id],
}
return summary
except BioyondException:
raise
@@ -669,6 +780,279 @@ class BioyondDispensingStation(BioyondWorkstation):
raise BioyondException(error_msg)
def wait_for_multiple_orders_and_get_reports(self,
batch_create_result: str = None,
timeout: int = 7200,
check_interval: int = 10) -> Dict[str, Any]:
"""
同时等待多个任务完成并获取实验报告
参数说明:
- batch_create_result: 批量创建任务的返回结果JSON字符串包含order_codes和order_ids数组
- timeout: 超时时间默认7200秒2小时
- check_interval: 检查间隔默认10秒
返回: 包含所有任务状态和报告的字典
{
"total": 2,
"completed": 2,
"timeout": 0,
"elapsed_time": 120.5,
"reports": [
{
"order_code": "task_vial_1",
"order_id": "uuid1",
"status": "completed",
"completion_status": 30,
"report": {...}
},
...
]
}
异常:
- BioyondException: 所有任务都超时或发生错误
"""
try:
# 参数类型转换
timeout = int(timeout) if timeout else 7200
check_interval = int(check_interval) if check_interval else 10
# 验证batch_create_result参数
if not batch_create_result or batch_create_result == "":
raise BioyondException("batch_create_result参数为空请确保从batch_create节点正确连接handle")
# 解析batch_create_result JSON对象
try:
# 清理可能存在的截断标记 [...]
if isinstance(batch_create_result, str) and '[...]' in batch_create_result:
batch_create_result = batch_create_result.replace('[...]', '[]')
result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result
# 兼容外层包装格式 {error, suc, return_value}
if isinstance(result_obj, dict) and "return_value" in result_obj:
inner = result_obj.get("return_value")
if isinstance(inner, str):
result_obj = json.loads(inner)
elif isinstance(inner, dict):
result_obj = inner
# 从summary对象中提取order_codes和order_ids
order_codes = result_obj.get("order_codes", [])
order_ids = result_obj.get("order_ids", [])
except json.JSONDecodeError as e:
raise BioyondException(f"解析batch_create_result失败: {e}")
except Exception as e:
raise BioyondException(f"处理batch_create_result时出错: {e}")
# 验证提取的数据
if not order_codes:
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
if not order_ids:
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
# 确保order_codes和order_ids是列表类型
if not isinstance(order_codes, list):
order_codes = [order_codes] if order_codes else []
if not isinstance(order_ids, list):
order_ids = [order_ids] if order_ids else []
codes_list = order_codes
ids_list = order_ids
if len(codes_list) != len(ids_list):
raise BioyondException(
f"order_codes数量({len(codes_list)})与order_ids数量({len(ids_list)})不匹配"
)
if not codes_list or not ids_list:
raise BioyondException("order_codes和order_ids不能为空")
# 初始化跟踪变量
total = len(codes_list)
pending_orders = {code: {"order_id": ids_list[i], "completed": False}
for i, code in enumerate(codes_list)}
reports = []
start_time = time.time()
self.hardware_interface._logger.info(
f"开始等待 {total} 个任务完成: {', '.join(codes_list)}"
)
# 轮询检查任务状态
while pending_orders:
elapsed_time = time.time() - start_time
# 检查超时
if elapsed_time > timeout:
# 收集超时任务
timeout_orders = list(pending_orders.keys())
self.hardware_interface._logger.error(
f"等待任务完成超时,剩余未完成任务: {', '.join(timeout_orders)}"
)
# 为超时任务添加记录
for order_code in timeout_orders:
reports.append({
"order_code": order_code,
"order_id": pending_orders[order_code]["order_id"],
"status": "timeout",
"completion_status": None,
"report": None,
"elapsed_time": elapsed_time
})
break
# 检查每个待完成的任务
completed_in_this_round = []
for order_code in list(pending_orders.keys()):
order_id = pending_orders[order_code]["order_id"]
# 检查任务是否完成
if order_code in self.order_completion_status:
completion_info = self.order_completion_status[order_code]
self.hardware_interface._logger.info(
f"检测到任务 {order_code} 已完成,状态: {completion_info.get('status')}"
)
# 获取实验报告
try:
report_query = json.dumps({"order_id": order_id})
report = self.hardware_interface.order_report(report_query)
if not report:
self.hardware_interface._logger.warning(
f"任务 {order_code} 已完成但无法获取报告"
)
report = {"error": "无法获取报告"}
else:
self.hardware_interface._logger.info(
f"成功获取任务 {order_code} 的实验报告"
)
reports.append({
"order_code": order_code,
"order_id": order_id,
"status": "completed",
"completion_status": completion_info.get('status'),
"report": report,
"elapsed_time": elapsed_time
})
# 标记为已完成
completed_in_this_round.append(order_code)
# 清理完成状态记录
del self.order_completion_status[order_code]
except Exception as e:
self.hardware_interface._logger.error(
f"查询任务 {order_code} 报告失败: {str(e)}"
)
reports.append({
"order_code": order_code,
"order_id": order_id,
"status": "error",
"completion_status": completion_info.get('status'),
"report": None,
"error": str(e),
"elapsed_time": elapsed_time
})
completed_in_this_round.append(order_code)
# 从待完成列表中移除已完成的任务
for order_code in completed_in_this_round:
del pending_orders[order_code]
# 如果还有待完成的任务,等待后继续
if pending_orders:
time.sleep(check_interval)
# 每分钟记录一次等待状态
new_elapsed_time = time.time() - start_time
if int(new_elapsed_time) % 60 == 0 and new_elapsed_time > 0:
self.hardware_interface._logger.info(
f"批量等待任务中... 已完成 {len(reports)}/{total}, "
f"待完成: {', '.join(pending_orders.keys())}, "
f"已等待 {int(new_elapsed_time/60)} 分钟"
)
# 统计结果
completed_count = sum(1 for r in reports if r['status'] == 'completed')
timeout_count = sum(1 for r in reports if r['status'] == 'timeout')
error_count = sum(1 for r in reports if r['status'] == 'error')
final_elapsed_time = time.time() - start_time
summary = {
"total": total,
"completed": completed_count,
"timeout": timeout_count,
"error": error_count,
"elapsed_time": round(final_elapsed_time, 2),
"reports": reports
}
self.hardware_interface._logger.info(
f"批量等待任务完成: 总数={total}, 成功={completed_count}, "
f"超时={timeout_count}, 错误={error_count}, 耗时={final_elapsed_time:.1f}"
)
# 返回字典格式,在顶层包含统计信息
return {
"return_info": json.dumps(summary, ensure_ascii=False)
}
except BioyondException:
raise
except Exception as e:
error_msg = f"批量等待任务完成时发生未预期的错误: {str(e)}"
self.hardware_interface._logger.error(error_msg)
raise BioyondException(error_msg)
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
"""
重写父类方法,处理任务完成报送并记录到 order_completion_status
Args:
report_request: WorkstationReportRequest 对象,包含任务完成信息
used_materials: 物料使用记录列表
Returns:
Dict[str, Any]: 处理结果
"""
try:
# 调用父类方法
result = super().process_order_finish_report(report_request, used_materials)
# 记录任务完成状态
data = report_request.data
order_code = data.get('orderCode')
if order_code:
self.order_completion_status[order_code] = {
'status': data.get('status'),
'order_name': data.get('orderName'),
'timestamp': datetime.now().isoformat(),
'start_time': data.get('startTime'),
'end_time': data.get('endTime')
}
self.hardware_interface._logger.info(
f"已记录任务完成状态: {order_code}, status={data.get('status')}"
)
return result
except Exception as e:
self.hardware_interface._logger.error(f"处理任务完成报送失败: {e}")
return {"processed": False, "error": str(e)}
if __name__ == "__main__":
bioyond = BioyondDispensingStation(config={
"api_key": "DE9BDDA0",
@@ -1089,4 +1473,3 @@ if __name__ == "__main__":
# id = "3a1bce3c-4f31-c8f3-5525-f3b273bc34dc"
# bioyond.sample_waste_removal(id)

View File

@@ -345,7 +345,7 @@ class BioyondReactionStation(BioyondWorkstation):
def liquid_feeding_beaker(
self,
volume: str = "35000",
volume: str = "350",
assign_material_name: str = "BAPP",
time: str = "0",
torque_variation: int = 1,
@@ -355,7 +355,7 @@ class BioyondReactionStation(BioyondWorkstation):
"""液体进料烧杯
Args:
volume: 分液量(μL)
volume: 分液量(g)
assign_material_name: 物料名称(试剂瓶位)
time: 观察时间(分钟)
torque_variation: 是否观察(int类型, 1=否, 2=是)
@@ -580,7 +580,14 @@ class BioyondReactionStation(BioyondWorkstation):
# print(f"\n✅ 任务创建成功: {result}")
# print(f"\n✅ 任务创建成功")
print(f"{'='*60}\n")
return json.dumps({"success": True, "result": result})
# 返回结果,包含合并后的工作流数据和订单参数
return json.dumps({
"success": True,
"result": result,
"merged_workflow": merged_workflow,
"order_params": order_params
})
def _build_workflows_with_parameters(self, workflows_result: list) -> list:
"""
@@ -780,4 +787,4 @@ class BioyondReactionStation(BioyondWorkstation):
except Exception as e:
print(f" ❌ 工作流ID验证失败: {e}")
print(f" 💡 将重新合并工作流")
return False
return False

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ Workstation HTTP Service Module
统一的工作站报送接收服务基于LIMS协议规范
1. 步骤完成报送 - POST /report/step_finish
2. 通量完成报送 - POST /report/sample_finish
2. 通量完成报送 - POST /report/sample_finish
3. 任务完成报送 - POST /report/order_finish
4. 批量更新报送 - POST /report/batch_update
5. 物料变更报送 - POST /report/material_change
@@ -54,18 +54,18 @@ class HttpResponse:
class WorkstationHTTPHandler(BaseHTTPRequestHandler):
"""工作站HTTP请求处理器"""
def __init__(self, workstation_instance, *args, **kwargs):
self.workstation = workstation_instance
super().__init__(*args, **kwargs)
def do_POST(self):
"""处理POST请求 - 统一的工作站报送接口"""
try:
# 解析请求路径
parsed_path = urlparse(self.path)
endpoint = parsed_path.path
# 读取请求体
content_length = int(self.headers.get('Content-Length', 0))
if content_length > 0:
@@ -73,9 +73,9 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
request_data = json.loads(post_data.decode('utf-8'))
else:
request_data = {}
logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}")
# 统一的报送端点路由基于LIMS协议规范
if endpoint == '/report/step_finish':
response = self._handle_step_finish_report(request_data)
@@ -102,18 +102,18 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"不支持的报送端点: {endpoint}",
data={"supported_endpoints": [
"/report/step_finish",
"/report/sample_finish",
"/report/step_finish",
"/report/sample_finish",
"/report/order_finish",
"/report/batch_update",
"/report/material_change",
"/report/error_handling"
]}
)
# 发送响应
self._send_response(response)
except Exception as e:
logger.error(f"处理工作站报送失败: {e}\\n{traceback.format_exc()}")
error_response = HttpResponse(
@@ -121,13 +121,13 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
message=f"请求处理失败: {str(e)}"
)
self._send_response(error_response)
def do_GET(self):
"""处理GET请求 - 健康检查和状态查询"""
try:
parsed_path = urlparse(self.path)
endpoint = parsed_path.path
if endpoint == '/status':
response = self._handle_status_check()
elif endpoint == '/health':
@@ -138,9 +138,9 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
message=f"不支持的查询端点: {endpoint}",
data={"supported_endpoints": ["/status", "/health"]}
)
self._send_response(response)
except Exception as e:
logger.error(f"GET请求处理失败: {e}")
error_response = HttpResponse(
@@ -148,7 +148,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
message=f"GET请求处理失败: {str(e)}"
)
self._send_response(error_response)
def do_OPTIONS(self):
"""处理OPTIONS请求 - CORS预检请求"""
try:
@@ -159,12 +159,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.send_header('Access-Control-Max-Age', '86400')
self.end_headers()
except Exception as e:
logger.error(f"OPTIONS请求处理失败: {e}")
self.send_response(500)
self.end_headers()
def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理步骤完成报送统一LIMS协议规范"""
try:
@@ -175,7 +175,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"缺少必要字段: {', '.join(missing_fields)}"
)
# 验证data字段内容
data = request_data['data']
data_required_fields = ['orderCode', 'orderName', 'stepName', 'stepId', 'sampleId', 'startTime', 'endTime']
@@ -184,31 +184,31 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}"
)
# 创建统一请求对象
report_request = WorkstationReportRequest(
token=request_data['token'],
request_time=request_data['request_time'],
data=data
)
# 调用工作站处理方法
result = self.workstation.process_step_finish_report(report_request)
return HttpResponse(
success=True,
message=f"步骤完成报送已处理: {data['stepName']} ({data['orderCode']})",
acknowledgment_id=f"STEP_{int(time.time() * 1000)}_{data['stepId']}",
data=result
)
except Exception as e:
logger.error(f"处理步骤完成报送失败: {e}")
return HttpResponse(
success=False,
message=f"步骤完成报送处理失败: {str(e)}"
)
def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理通量完成报送统一LIMS协议规范"""
try:
@@ -219,7 +219,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"缺少必要字段: {', '.join(missing_fields)}"
)
# 验证data字段内容
data = request_data['data']
data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'status']
@@ -228,37 +228,37 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}"
)
# 创建统一请求对象
report_request = WorkstationReportRequest(
token=request_data['token'],
request_time=request_data['request_time'],
data=data
)
# 调用工作站处理方法
result = self.workstation.process_sample_finish_report(report_request)
status_names = {
"0": "待生产", "2": "进样", "10": "开始",
"0": "待生产", "2": "进样", "10": "开始",
"20": "完成", "-2": "异常停止", "-3": "人工停止"
}
status_desc = status_names.get(str(data['status']), f"状态{data['status']}")
return HttpResponse(
success=True,
message=f"通量完成报送已处理: {data['sampleId']} ({data['orderCode']}) - {status_desc}",
acknowledgment_id=f"SAMPLE_{int(time.time() * 1000)}_{data['sampleId']}",
data=result
)
except Exception as e:
logger.error(f"处理通量完成报送失败: {e}")
return HttpResponse(
success=False,
message=f"通量完成报送处理失败: {str(e)}"
)
def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理任务完成报送统一LIMS协议规范"""
try:
@@ -269,7 +269,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"缺少必要字段: {', '.join(missing_fields)}"
)
# 验证data字段内容
data = request_data['data']
data_required_fields = ['orderCode', 'orderName', 'startTime', 'endTime', 'status']
@@ -278,7 +278,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}"
)
# 处理物料使用记录
used_materials = []
if 'usedMaterials' in data:
@@ -290,41 +290,41 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
usedQuantity=material_data.get('usedQuantity', 0.0)
)
used_materials.append(material)
# 创建统一请求对象
report_request = WorkstationReportRequest(
token=request_data['token'],
request_time=request_data['request_time'],
data=data
)
# 调用工作站处理方法
result = self.workstation.process_order_finish_report(report_request, used_materials)
status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"}
status_desc = status_names.get(str(data['status']), f"状态{data['status']}")
return HttpResponse(
success=True,
message=f"任务完成报送已处理: {data['orderName']} ({data['orderCode']}) - {status_desc}",
acknowledgment_id=f"ORDER_{int(time.time() * 1000)}_{data['orderCode']}",
data=result
)
except Exception as e:
logger.error(f"处理任务完成报送失败: {e}")
return HttpResponse(
success=False,
message=f"任务完成报送处理失败: {str(e)}"
)
def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理批量报送"""
try:
step_updates = request_data.get('step_updates', [])
sample_updates = request_data.get('sample_updates', [])
order_updates = request_data.get('order_updates', [])
results = {
'step_results': [],
'sample_results': [],
@@ -332,7 +332,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
'total_processed': 0,
'total_failed': 0
}
# 处理批量步骤更新
for step_data in step_updates:
try:
@@ -347,7 +347,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
except Exception as e:
results['step_results'].append(HttpResponse(success=False, message=str(e)))
results['total_failed'] += 1
# 处理批量通量更新
for sample_data in sample_updates:
try:
@@ -362,7 +362,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
except Exception as e:
results['sample_results'].append(HttpResponse(success=False, message=str(e)))
results['total_failed'] += 1
# 处理批量任务更新
for order_data in order_updates:
try:
@@ -377,21 +377,21 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
except Exception as e:
results['order_results'].append(HttpResponse(success=False, message=str(e)))
results['total_failed'] += 1
return HttpResponse(
success=results['total_failed'] == 0,
message=f"批量报送处理完成: {results['total_processed']} 成功, {results['total_failed']} 失败",
acknowledgment_id=f"BATCH_{int(time.time() * 1000)}",
data=results
)
except Exception as e:
logger.error(f"处理批量报送失败: {e}")
return HttpResponse(
success=False,
message=f"批量报送处理失败: {str(e)}"
)
def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理物料变更报送"""
try:
@@ -417,24 +417,24 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"缺少必要字段: {', '.join(missing_fields)}"
)
# 调用工作站的处理方法
result = self.workstation.process_material_change_report(request_data)
return HttpResponse(
success=True,
message=f"物料变更报送已处理: {request_data['resource_id']} ({request_data['change_type']})",
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{request_data['resource_id']}",
data=result
)
except Exception as e:
logger.error(f"处理物料变更报送失败: {e}")
return HttpResponse(
success=False,
message=f"物料变更报送处理失败: {str(e)}"
)
def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理错误处理报送"""
try:
@@ -446,13 +446,13 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message="奔曜格式缺少text字段"
)
error_data = request_data["text"]
logger.info(f"收到奔曜错误处理报送: {error_data}")
# 调用工作站的处理方法
result = self.workstation.handle_external_error(error_data)
return HttpResponse(
success=True,
message=f"错误处理报送已收到: 任务{error_data.get('task', 'unknown')}, 错误代码{error_data.get('code', 'unknown')}",
@@ -467,38 +467,45 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"缺少必要字段: {', '.join(missing_fields)}"
)
# 调用工作站的处理方法
result = self.workstation.handle_external_error(request_data)
return HttpResponse(
success=True,
message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}",
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}",
data=result
)
except Exception as e:
logger.error(f"处理错误处理报送失败: {e}")
return HttpResponse(
success=False,
message=f"错误处理报送处理失败: {str(e)}"
)
def _handle_status_check(self) -> HttpResponse:
"""处理状态查询"""
try:
# 安全地获取 device_id
device_id = "unknown"
if hasattr(self.workstation, 'device_id'):
device_id = self.workstation.device_id
elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'):
device_id = self.workstation._ros_node.device_id
return HttpResponse(
success=True,
message="工作站报送服务正常运行",
data={
"workstation_id": self.workstation.device_id,
"workstation_id": device_id,
"service_type": "unified_reporting_service",
"uptime": time.time() - getattr(self.workstation, '_start_time', time.time()),
"reports_received": getattr(self.workstation, '_reports_received_count', 0),
"supported_endpoints": [
"POST /report/step_finish",
"POST /report/sample_finish",
"POST /report/sample_finish",
"POST /report/order_finish",
"POST /report/batch_update",
"POST /report/material_change",
@@ -514,28 +521,28 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
success=False,
message=f"状态查询失败: {str(e)}"
)
def _send_response(self, response: HttpResponse):
"""发送响应"""
try:
# 设置响应状态码
status_code = 200 if response.success else 400
self.send_response(status_code)
# 设置响应头
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
# 发送响应体
response_json = json.dumps(asdict(response), ensure_ascii=False, indent=2)
self.wfile.write(response_json.encode('utf-8'))
except Exception as e:
logger.error(f"发送响应失败: {e}")
def log_message(self, format, *args):
"""重写日志方法"""
logger.debug(f"HTTP请求: {format % args}")
@@ -543,7 +550,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
class WorkstationHTTPService:
"""工作站HTTP服务"""
def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 8080):
self.workstation = workstation_instance
self.host = host
@@ -551,31 +558,38 @@ class WorkstationHTTPService:
self.server = None
self.server_thread = None
self.running = False
# 初始化统计信息
self.workstation._start_time = time.time()
self.workstation._reports_received_count = 0
def start(self):
"""启动HTTP服务"""
try:
# 创建处理器工厂函数
def handler_factory(*args, **kwargs):
return WorkstationHTTPHandler(self.workstation, *args, **kwargs)
# 创建HTTP服务器
self.server = HTTPServer((self.host, self.port), handler_factory)
# 安全地获取 device_id 用于线程命名
device_id = "unknown"
if hasattr(self.workstation, 'device_id'):
device_id = self.workstation.device_id
elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'):
device_id = self.workstation._ros_node.device_id
# 在单独线程中运行服务器
self.server_thread = threading.Thread(
target=self._run_server,
daemon=True,
name=f"WorkstationHTTP-{self.workstation.device_id}"
name=f"WorkstationHTTP-{device_id}"
)
self.running = True
self.server_thread.start()
logger.info(f"工作站HTTP报送服务已启动: http://{self.host}:{self.port}")
logger.info("统一的报送端点 (基于LIMS协议规范):")
logger.info(" - POST /report/step_finish # 步骤完成报送")
@@ -592,33 +606,33 @@ class WorkstationHTTPService:
logger.info("服务端点:")
logger.info(" - GET /status # 服务状态查询")
logger.info(" - GET /health # 健康检查")
except Exception as e:
logger.error(f"启动HTTP服务失败: {e}")
raise
def stop(self):
"""停止HTTP服务"""
try:
if self.running and self.server:
logger.info("正在停止工作站HTTP报送服务...")
self.running = False
# 停止serve_forever循环
self.server.shutdown()
# 等待服务器线程结束
if self.server_thread and self.server_thread.is_alive():
self.server_thread.join(timeout=5.0)
# 关闭服务器套接字
self.server.server_close()
logger.info("工作站HTTP报送服务已停止")
except Exception as e:
logger.error(f"停止HTTP服务失败: {e}")
def _run_server(self):
"""运行HTTP服务器"""
try:
@@ -629,12 +643,12 @@ class WorkstationHTTPService:
logger.error(f"HTTP服务运行错误: {e}")
finally:
logger.info("HTTP服务器线程已退出")
@property
def is_running(self) -> bool:
"""检查服务是否正在运行"""
return self.running and self.server_thread and self.server_thread.is_alive()
@property
def service_url(self) -> str:
"""获取服务URL"""
@@ -648,7 +662,7 @@ class MaterialChangeReport:
pass
@dataclass
@dataclass
class TaskExecutionReport:
"""已废弃任务执行报送请使用统一的WorkstationReportRequest"""
pass
@@ -670,38 +684,38 @@ if __name__ == "__main__":
# 简单测试HTTP服务
class BioyondWorkstation:
device_id = "WS-001"
def process_step_finish_report(self, report_request):
return {"processed": True}
def process_sample_finish_report(self, report_request):
return {"processed": True}
def process_order_finish_report(self, report_request, used_materials):
return {"processed": True}
def process_material_change_report(self, report_data):
return {"processed": True}
def handle_external_error(self, error_data):
return {"handled": True}
workstation = DummyWorkstation()
workstation = BioyondWorkstation()
http_service = WorkstationHTTPService(workstation)
try:
http_service.start()
print(f"测试服务器已启动: {http_service.service_url}")
print("按 Ctrl+C 停止服务器")
print("服务将持续运行等待接收HTTP请求...")
# 保持服务器运行 - 使用更好的等待机制
try:
while http_service.is_running:
time.sleep(1)
except KeyboardInterrupt:
print("\n接收到停止信号...")
except KeyboardInterrupt:
print("\n正在停止服务器...")
http_service.stop()

View File

@@ -29,10 +29,17 @@ bioyond_dispensing_station:
handler_key: titration
io_type: source
label: Titration Data From Calculation Node
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: sink
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
result:
return_info: return_info
schema:
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
properties:
feedback:
properties: {}
@@ -72,6 +79,7 @@ bioyond_dispensing_station:
result:
properties:
return_info:
description: 批量任务创建结果汇总JSON字符串包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
type: string
required:
- return_info
@@ -104,10 +112,17 @@ bioyond_dispensing_station:
handler_key: solutions
io_type: source
label: Solution Data From Python
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: sink
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
result:
return_info: return_info
schema:
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
properties:
feedback:
properties: {}
@@ -145,7 +160,7 @@ bioyond_dispensing_station:
result:
properties:
return_info:
description: 批量任务创建结果汇总JSON格式包含总数、成功数、失败数及每个任务的详细信息
description: 批量任务创建结果汇总JSON字符串包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
type: string
required:
- return_info
@@ -382,6 +397,74 @@ bioyond_dispensing_station:
title: DispenStationSolnPrep
type: object
type: DispenStationSolnPrep
wait_for_multiple_orders_and_get_reports:
feedback: {}
goal:
batch_create_result: batch_create_result
check_interval: check_interval
timeout: timeout
goal_default:
batch_create_result: ''
check_interval: '10'
timeout: '7200'
handles:
input:
- data_key: batch_create_result
data_source: handle
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: source
label: Batch Task Creation Result From Previous Step
output:
- data_key: return_info
data_source: handle
data_type: string
handler_key: batch_reports_result
io_type: sink
label: Batch Order Completion Reports
result:
return_info: return_info
schema:
description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象并行监控所有任务状态并返回每个任务的报告。
properties:
feedback:
properties: {}
required: []
title: WaitForMultipleOrdersAndGetReports_Feedback
type: object
goal:
properties:
batch_create_result:
description: 批量创建任务的返回结果对象包含order_codes和order_ids数组。从上游batch_create节点通过handle传递
type: string
check_interval:
default: '10'
description: 检查任务状态的时间间隔默认每10秒检查一次所有待完成任务
type: string
timeout:
default: '7200'
description: 等待超时时间默认7200秒2小时。超过此时间未完成的任务将标记为timeout
type: string
required:
- batch_create_result
title: WaitForMultipleOrdersAndGetReports_Goal
type: object
result:
properties:
return_info:
description: 'JSON格式的批量任务完成信息包含: total(总数), completed(成功数), timeout(超时数),
error(错误数), elapsed_time(总耗时), reports(报告数组每个元素包含order_code,
order_id, status, completion_status, report, elapsed_time)'
type: string
required:
- return_info
title: WaitForMultipleOrdersAndGetReports_Result
type: object
required:
- goal
title: WaitForMultipleOrdersAndGetReports
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
status_types: {}
type: python

View File

@@ -34,13 +34,13 @@ BIOYOND_PolymerStation_6StockCarrier:
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerStation_6VialCarrier:
BIOYOND_PolymerStation_8StockCarrier:
category:
- bottle_carriers
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6VialCarrier
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_8StockCarrier
type: pylabrobot
description: BIOYOND_PolymerStation_6VialCarrier
description: BIOYOND_PolymerStation_8StockCarrier (2x4布局8个位置)
handles: []
icon: ''
init_param_schema: {}

View File

@@ -1,24 +1,3 @@
BIOYOND_PolymerStation_Liquid_Vial:
category:
- bottles
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial
type: pylabrobot
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Reactor:
category:
- bottles
- reactors
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reactor
type: pylabrobot
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Reagent_Bottle:
category:
- bottles
@@ -49,6 +28,16 @@ BIOYOND_PolymerStation_Solid_Vial:
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Liquid_Vial:
category:
- bottles
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial
type: pylabrobot
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Solution_Beaker:
category:
- bottles
@@ -70,3 +59,26 @@ BIOYOND_PolymerStation_TipBox:
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Reactor:
category:
- bottles
- reactors
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reactor
type: pylabrobot
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Flask:
category:
- bottles
- flasks
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Flask
type: pylabrobot
description: 聚合站-烧杯容器
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0

View File

@@ -1,16 +1,242 @@
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 BottleCarrier
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
BIOYOND_PolymerStation_Reagent_Bottle,
BIOYOND_PolymerStation_Flask,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask,小瓶-Vial
# ============================================================================
# 聚合站PolymerStation载体定义统一入口
# ============================================================================
def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier:
"""聚合站-6孔样品板 - 2x3布局
参数:
- name: 载架名称前缀
说明:
- 统一站点命名为 PolymerStation使用 PolymerStation 的 Vial 资源类
- A行PLR y=0对应 Bioyond 位置A01~A03使用 Liquid_Vial10% 分装小瓶)
- B行PLR y=1对应 Bioyond 位置B01~B03使用 Solid_Vial90% 分装小瓶)
"""
# 载架尺寸 (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_6StockCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
# 布局说明:
# - num_items_x=3, num_items_y=2 表示 3列×2行
# - create_ordered_items_2d 按先y后x的顺序创建(列优先)
# - 索引顺序: 0=A1(x=0,y=0), 1=B1(x=0,y=1), 2=A2(x=1,y=0), 3=B2(x=1,y=1), 4=A3(x=2,y=0), 5=B3(x=2,y=1)
#
# Bioyond坐标映射: PLR(x,y) → Bioyond(y+1,x+1)
# - A行(PLR y=0) → Bioyond x=1 → 10%分装小瓶
# - B行(PLR y=1) → Bioyond x=2 → 90%分装小瓶
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
for col in range(3): # 3列
for row in range(2): # 2行
idx = col * 2 + row # 计算索引: 列优先顺序
if row == 0: # A行 (PLR y=0 → Bioyond x=1)
carrier[idx] = BIOYOND_PolymerStation_Liquid_Vial(f"{ordering[idx]}")
else: # B行 (PLR y=1 → Bioyond x=2)
carrier[idx] = BIOYOND_PolymerStation_Solid_Vial(f"{ordering[idx]}")
return carrier
def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier:
"""聚合站-8孔样品板 - 2x4布局
参数:
- name: 载架名称前缀
说明:
- 统一站点命名为 PolymerStation使用 PolymerStation 的 Solid_Stock 资源类
"""
# 载架尺寸 (mm)
carrier_size_x = 128.0
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 20.0
bottle_spacing_x = 30.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (4 - 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=4,
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_8StockCarrier",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "B1", "A2", "B2", "A3", "B3", "A4", "B4"]
for i in range(8):
carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}")
return carrier
def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier:
"""聚合站-单试剂瓶载架
参数:
- name: 载架名称前缀
"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯/试剂瓶占位尺寸(使用圆形占位)
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_PolymerStation_1BottleCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
# 统一后缀采用 "flask_1" 命名(可按需调整)
carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1")
return carrier
def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier:
"""聚合站-单烧杯载架
说明:
- 使用 BIOYOND_PolymerStation_Flask 资源类
- 载架命名与 model 统一为 PolymerStation
"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_PolymerStation_1FlaskCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Flask(f"{name}_flask_1")
return carrier
# ============================================================================
# 其他载体定义
# ============================================================================
def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
@@ -96,181 +322,3 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
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:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 30.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(3):
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
def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier:
"""1瓶载架 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_PolymerStation_1BottleCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1")
return carrier
def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier:
"""1瓶载架 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 70.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_PolymerStation_1FlaskCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_bottle_1")
return carrier

View File

@@ -1,5 +1,4 @@
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数
from unilabos.resources.itemized_carrier import Bottle
def BIOYOND_PolymerStation_Solid_Stock(
@@ -176,3 +175,21 @@ def BIOYOND_PolymerStation_TipBox(
)
return tip_box
def BIOYOND_PolymerStation_Flask(
name: str,
diameter: float = 60.0,
height: float = 70.0,
max_volume: float = 200000.0, # 200mL
barcode: str = None,
) -> Bottle:
"""聚合站-烧杯(统一 Flask 资源到 PolymerStation"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Flask",
)

View File

@@ -5,6 +5,7 @@ from unilabos.resources.bioyond.warehouses import (
bioyond_warehouse_1x4x4,
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08)
bioyond_warehouse_1x4x2,
bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4)
bioyond_warehouse_liquid_and_lid_handling,
bioyond_warehouse_1x2x2,
bioyond_warehouse_1x3x3,
@@ -48,9 +49,9 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
self.warehouse_locations = {
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
"站内试剂存放堆栈": Coordinate(1100.0, 475.0, 0.0),
"移液站内10%分装液体准备仓库": Coordinate(1500.0, 300.0, 0.0),
"站内Tip盒堆栈": Coordinate(1800.0, 300.0, 0.0), # TODO: 根据实际位置调整坐标
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
"移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
}
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
@@ -73,18 +74,21 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
self.setup()
def setup(self) -> None:
# 添加仓库
# 添加仓库 - 配液站的3个堆栈使用Bioyond系统中的实际名称
# 样品类型typeMode=1烧杯、试剂瓶、分装板 → 试剂堆栈、溶液堆栈
# 试剂类型typeMode=2样品板 → 粉末堆栈
self.warehouses = {
"io_warehouse_left": bioyond_warehouse_1x4x4("io_warehouse_left"),
"io_warehouse_right": bioyond_warehouse_1x4x4("io_warehouse_right"),
"solutions": bioyond_warehouse_1x4x2("warehouse_solutions"),
"liquid_and_lid_handling": bioyond_warehouse_liquid_and_lid_handling("warehouse_liquid_and_lid_handling"),
# 试剂类型 - 样品板
"粉末堆栈": bioyond_warehouse_1x4x4("粉末堆栈"), # 4行×4列 (A01-D04)
# 样品类型 - 烧杯、试剂瓶、分装板
"试剂堆栈": bioyond_warehouse_reagent_stack("试剂堆栈"), # 2行×4列 (A01-B04)
"溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04)
}
self.warehouse_locations = {
"io_warehouse_left": Coordinate(0.0, 650.0, 0.0),
"io_warehouse_right": Coordinate(2550.0, 650.0, 0.0),
"solutions": Coordinate(1915.0, 900.0, 0.0),
"liquid_and_lid_handling": Coordinate(1330.0, 490.0, 0.0),
"粉末堆栈": Coordinate(0.0, 450.0, 0.0),
"试剂堆栈": Coordinate(1850.0, 200.0, 0.0),
"溶液堆栈": Coordinate(2500.0, 450.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():

View File

@@ -2,12 +2,19 @@ from unilabos.resources.warehouse import WareHouse, warehouse_factory
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
"""创建BioYond 4x4x1仓库 (左侧堆栈: A01D04)"""
"""创建BioYond 4x4x1仓库 (左侧堆栈: A01D04)
使用行优先排序,前端展示为:
A01 | A02 | A03 | A04
B01 | B02 | B03 | B04
C01 | C02 | C03 | C04
D01 | D02 | D03 | D04
"""
return warehouse_factory(
name=name,
num_items_x=1,
num_items_y=4,
num_items_z=4,
num_items_x=4, # 4列
num_items_y=4, # 4行
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
@@ -16,6 +23,7 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
item_dz=120.0,
category="warehouse",
col_offset=0, # 从01开始: A01, A02, A03, A04
layout="row-major", # ⭐ 改为行优先排序
)
@@ -34,6 +42,7 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
item_dz=130.0,
category="warehouse",
col_offset=4, # 从05开始: A05, A06, A07, A08
layout="row-major", # ⭐ 改为行优先排序
)
@@ -54,7 +63,31 @@ def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
category="warehouse",
removed_positions=None
)
# 定义benyond的堆栈
def bioyond_warehouse_reagent_stack(name: str) -> WareHouse:
"""创建BioYond 试剂堆栈 2x4x1 (2行×4列: A01-A04, B01-B04)
使用行优先排序,前端展示为:
A01 | A02 | A03 | A04
B01 | B02 | B03 | B04
"""
return warehouse_factory(
name=name,
num_items_x=4, # 4列 (01-04)
num_items_y=2, # 2行 (A-B)
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=147.0,
item_dy=106.0,
item_dz=130.0,
category="warehouse",
col_offset=0, # 从01开始
layout="row-major", # ⭐ 使用行优先排序: A01,A02,A03,A04, B01,B02,B03,B04
)
# 定义bioyond的堆栈
def bioyond_warehouse_1x2x2(name: str) -> WareHouse:
"""创建BioYond 4x1x4仓库"""
return warehouse_factory(
@@ -228,6 +261,8 @@ def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse:
item_dy=96.0,
item_dz=120.0,
category="warehouse",
col_offset=0,
layout="row-major",
)
@@ -245,4 +280,6 @@ def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
col_offset=0,
layout="row-major",
)

View File

@@ -11,7 +11,7 @@ from unilabos_msgs.msg import Resource
from unilabos.config.config import BasicConfig
from unilabos.resources.container import RegularContainer
from unilabos.resources.itemized_carrier import ItemizedCarrier
from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier
from unilabos.ros.msgs.message_converter import convert_to_ros_msg
from unilabos.ros.nodes.resource_tracker import (
ResourceDictInstance,
@@ -228,7 +228,7 @@ def handle_communications(G: nx.Graph):
if G.nodes[device_comm].get("class") == "serial":
G.nodes[device]["config"]["port"] = device_comm
elif G.nodes[device_comm].get("class") == "io_device":
print(f'!!! Modify {device}\'s io_device_port to {edata["port"][device_comm]}')
logger.warning(f'Modify {device}\'s io_device_port to {edata["port"][device_comm]}')
G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm])
@@ -586,7 +586,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
if source in replace_info:
return replace_info[source]
else:
print("转换pylabrobot的时候出现未知类型", source)
logger.warning(f"转换pylabrobot的时候出现未知类型: {source}")
return source
def resource_plr_to_ulab_inner(d: dict, all_states: dict, child=True) -> dict:
@@ -621,7 +621,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
Args:
bioyond_materials: bioyond 系统的物料查询结果列表
type_mapping: 物料类型映射字典,格式 {bioyond_type: [plr_class_name, class_uuid]}
type_mapping: 物料类型映射字典,格式 {model: (显示名称, UUID)} 或 {显示名称: (model, UUID)}
location_id_mapping: 库位 ID 到名称的映射字典,格式 {location_id: location_name}
Returns:
@@ -629,13 +629,41 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
"""
plr_materials = []
# 创建反向映射: {显示名称: (model, UUID)} -> 用于从 Bioyond typeName 查找 model
# 如果 type_mapping 的 key 已经是显示名称,则直接使用;否则创建反向映射
reverse_type_mapping = {}
for key, value in type_mapping.items():
# value 可能是 tuple 或 list: (显示名称, UUID) 或 [显示名称, UUID]
display_name = value[0] if isinstance(value, (tuple, list)) and len(value) >= 1 else None
if display_name:
# 反向映射: {显示名称: (原始key作为model, UUID)}
resource_uuid = value[1] if len(value) >= 2 else ""
# 如果已存在该显示名称,跳过(保留第一个遇到的映射)
if display_name not in reverse_type_mapping:
reverse_type_mapping[display_name] = (key, resource_uuid)
logger.debug(f"[反向映射表] 共 {len(reverse_type_mapping)} 个条目: {list(reverse_type_mapping.keys())}")
# 用于跟踪同名物料的计数器
name_counter = {}
for material in bioyond_materials:
className = (
type_mapping.get(material.get("typeName"), ("RegularContainer", ""))[0] if type_mapping else "RegularContainer"
)
# 从反向映射中查找: typeName(显示名称) -> (model, UUID)
type_info = reverse_type_mapping.get(material.get("typeName"))
className = type_info[0] if type_info else "RegularContainer"
# 为同名物料添加唯一后缀
base_name = material["name"]
if base_name in name_counter:
name_counter[base_name] += 1
unique_name = f"{base_name}_{name_counter[base_name]}"
else:
name_counter[base_name] = 1
unique_name = base_name
plr_material_result = initialize_resource(
{"name": material["name"], "class": className}, resource_type=ResourcePLR
{"name": unique_name, "class": className}, resource_type=ResourcePLR
)
# initialize_resource 可能返回列表或单个对象
@@ -649,32 +677,77 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
# 确保 plr_material 是 ResourcePLR 实例
if not isinstance(plr_material, ResourcePLR):
logger.warning(f"物料 {material['name']} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}")
logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}")
continue
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
plr_material.unilabos_uuid = str(uuid.uuid4())
# ⭐ 保存 Bioyond 原始信息到 unilabos_extra用于出库时查询
plr_material.unilabos_extra = {
"material_bioyond_id": material.get("id"), # Bioyond 物料 UUID
"material_bioyond_name": material.get("name"), # Bioyond 原始名称(如 "MDA"
"material_bioyond_type": material.get("typeName"), # Bioyond 物料类型名称
}
logger.debug(f"[转换物料] {material['name']} (ID:{material['id']}) → {unique_name} (类型:{className})")
# 处理子物料detail
if material.get("detail") and len(material["detail"]) > 0:
for bottle in reversed(plr_material.children):
plr_material.unassign_child_resource(bottle)
child_ids = []
# 确定detail物料的默认类型
# 样品板的detail通常是样品瓶
default_detail_type = "样品瓶" if "样品板" in material.get("typeName", "") else None
for detail in material["detail"]:
number = (
(detail.get("z", 0) - 1) * plr_material.num_items_x * plr_material.num_items_y
+ (detail.get("y", 0) - 1) * plr_material.num_items_y
+ (detail.get("x", 0) - 1)
)
typeName = detail.get("typeName", detail.get("name", ""))
if typeName in type_mapping:
# 检查索引是否超出范围
max_index = plr_material.num_items_x * plr_material.num_items_y - 1
if number < 0 or number > max_index:
logger.warning(
f" └─ [子物料警告] {detail['name']} 的坐标 (x={detail.get('x')}, y={detail.get('y')}, z={detail.get('z')}) "
f"计算出索引 {number} 超出载架范围 [0-{max_index}] (布局: {plr_material.num_items_x}×{plr_material.num_items_y}),跳过"
)
continue
# detail可能没有typeName尝试从name推断或使用默认类型
typeName = detail.get("typeName")
# 如果没有typeName尝试根据父物料类型和位置推断
if not typeName:
if "分装板" in material.get("typeName", ""):
# 分装板: 根据行(x)判断类型
# 第一行(x=1)是10%分装小瓶,第二行(x=2)是90%分装小瓶
x_pos = detail.get("x", 0)
y_pos = detail.get("y", 0)
# logger.debug(f" └─ [推断类型] {detail['name']} 坐标(x={x_pos}, y={y_pos})")
if x_pos == 1:
typeName = "10%分装小瓶"
elif x_pos == 2:
typeName = "90%分装小瓶"
# logger.debug(f" └─ [推断结果] {detail['name']} → {typeName}")
else:
typeName = default_detail_type
if typeName and typeName in reverse_type_mapping:
bottle = plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": type_mapping[typeName][0]}, resource_type=ResourcePLR
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
)
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "")
logger.debug(f" └─ [子物料] {detail['name']}{plr_material.name}[{number}] (类型:{typeName})")
else:
logger.warning(f" └─ [子物料警告] {detail['name']} 的类型 '{typeName}' 不在mapping中跳过")
else:
# 只对有 capacity 属性的容器(液体容器)处理液体追踪
if hasattr(plr_material, 'capacity'):
@@ -686,8 +759,13 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
plr_materials.append(plr_material)
if deck and hasattr(deck, "warehouses"):
for loc in material.get("locations", []):
locations = material.get("locations", [])
if not locations:
logger.debug(f"[物料位置] {unique_name} 没有location信息跳过warehouse放置")
for loc in locations:
wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
# 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右"
# 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧
@@ -703,6 +781,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if hasattr(deck, "warehouses") and wh_name in deck.warehouses:
warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
@@ -721,78 +800,301 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
idx = y - 1
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
else:
# 多行warehouse: 使用列优先索引 (与Bioyond坐标系统一致)
# warehouse keys顺序: A01,B01,C01,D01, A02,B02,C02,D02, ...
# 索引计算: idx = (col-1) * num_rows + (row-1) + (layer-1) * (rows * cols)
# 多行warehouse: 根据 layout 使用不同的索引计算
row_idx = x - 1 # x表示行: 转为0-based
col_idx = y - 1 # y表示列: 转为0-based
layer_idx = z - 1 # 转为0-based
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx
logger.debug(f"多行warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
# 检查 warehouse 的排序方式属性
ordering_layout = getattr(warehouse, 'ordering_layout', 'col-major')
logger.debug(f"🔍 Warehouse {wh_name} layout检测: hasattr={hasattr(warehouse, 'ordering_layout')}, ordering_layout值='{ordering_layout}', warehouse类型={type(warehouse).__name__}")
if ordering_layout == "row-major":
# 行优先: A01,A02,A03,A04, B01,B02,B03,B04 (所有Bioyond堆栈)
# 索引计算: idx = (row) * num_cols + (col) + (layer) * (rows * cols)
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx
logger.debug(f"行优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
else:
# 列优先 (后备): A01,B01,C01,D01, A02,B02,C02,D02
# 索引计算: idx = (col) * num_rows + (row) + (layer) * (rows * cols)
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx
logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
warehouse[idx] = plr_material
logger.debug(f"✅ 物料 {material['name']} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
else:
logger.warning(f"物料 {material['name']} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
logger.warning(f"物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
else:
if wh_name:
logger.warning(f"❌ 物料 {unique_name} 的warehouse '{wh_name}' 在deck中不存在。可用warehouses: {list(deck.warehouses.keys()) if hasattr(deck, 'warehouses') else ''}")
return plr_materials
def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]:
def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}, material_params: dict = {}) -> list[dict]:
"""
将 PyLabRobot 资源转换为 Bioyond 格式
Args:
plr_resources: PyLabRobot 资源列表
type_mapping: 物料类型映射字典
warehouse_mapping: 仓库映射字典
material_params: 物料默认参数字典 (格式: {物料名称: {参数字典}})
Returns:
Bioyond 格式的物料列表
"""
bioyond_materials = []
# 定义不需要发送 details 的载架类型
# 说明:这些载架上自带试剂瓶或烧杯,作为整体物料上传即可,不需要在 details 中重复上传子物料
CARRIERS_WITHOUT_DETAILS = {
"BIOYOND_PolymerStation_1BottleCarrier", # 聚合站-单试剂瓶载架
"BIOYOND_PolymerStation_1FlaskCarrier", # 聚合站-单烧杯载架
}
for resource in plr_resources:
if hasattr(resource, "capacity") and resource.capacity > 1:
if isinstance(resource, BottleCarrier) and resource.capacity > 1:
# 获取 BottleCarrier 的类型映射
type_info = type_mapping.get(resource.model)
if not type_info:
logger.error(f"❌ [PLR→Bioyond] BottleCarrier 资源 '{resource.name}' 的 model '{resource.model}' 不在 type_mapping 中")
logger.debug(f"[PLR→Bioyond] 可用的 type_mapping 键: {list(type_mapping.keys())}")
raise ValueError(f"资源 model '{resource.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置")
material = {
"typeId": type_mapping.get(resource.model)[1],
"typeId": type_info[1],
"code": "",
"barCode": "",
"name": resource.name,
"unit": "",
"quantity": 1,
"details": [],
"Parameters": "{}"
}
for bottle in resource.children:
if isinstance(resource, ItemizedCarrier):
site = resource.get_child_identifier(bottle)
else:
site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1}
detail_item = {
"typeId": type_mapping.get(bottle.model)[1],
"name": bottle.name,
"code": bottle.code if hasattr(bottle, "code") else "",
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"x": site["x"] + 1,
"y": site["y"] + 1,
"molecular": 1,
"Parameters": json.dumps({"molecular": 1})
}
material["details"].append(detail_item)
else:
bottle = resource[0] if resource.capacity > 0 else resource
material = {
"typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a",
"name": resource.name if hasattr(resource, "name") else "",
"unit": "", # 修复Bioyond API 要求 unit 字段不能为空
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"Parameters": "{}"
"Parameters": "{}" # API 实际要求的字段(必需)
}
if resource.parent is not None and isinstance(resource.parent, ItemizedCarrier):
# 如果是自带试剂瓶的载架类型不处理子物料details留空
if resource.model in CARRIERS_WITHOUT_DETAILS:
logger.info(f"[PLR→Bioyond] 载架 '{resource.name}' (model: {resource.model}) 自带试剂瓶,不添加 details")
else:
# 处理其他载架类型的子物料
for bottle in resource.children:
if isinstance(resource, ItemizedCarrier):
# ⭐ 优化:直接使用 get_child_identifier 获取真实的子物料坐标
# 这个方法会遍历 resource.children 找到 bottle 对象的实际位置
site = resource.get_child_identifier(bottle)
# 🔧 如果 get_child_identifier 失败或返回无效坐标 (0,0)
# 这通常发生在子物料名称使用纯数字后缀时(如 "BTDA_0", "BTDA_4"
if not site or (site.get("x") == 0 and site.get("y") == 0):
# 方法1: 尝试从名称中提取标识符并解析
bottle_identifier = None
if "_" in bottle.name:
bottle_identifier = bottle.name.split("_")[-1]
# 只有非纯数字标识符才尝试解析(如 "A1", "B2"
if bottle_identifier and not bottle_identifier.isdigit():
try:
x_idx, y_idx, z_idx = resource._parse_identifier_to_indices(bottle_identifier, 0)
site = {"x": x_idx, "y": y_idx, "z": z_idx, "identifier": bottle_identifier}
logger.debug(f" 🔧 [坐标修正-方法1] 从名称 {bottle.name} 解析标识符 {bottle_identifier} → ({x_idx}, {y_idx})")
except Exception as e:
logger.warning(f" ⚠️ [坐标解析] 标识符 {bottle_identifier} 解析失败: {e}")
# 方法2: 如果方法1失败使用线性索引反推坐标
if not site or (site.get("x") == 0 and site.get("y") == 0):
# 找到bottle在children中的索引位置
try:
# 遍历所有槽位找到bottle的实际位置
for idx in range(resource.num_items_x * resource.num_items_y):
if resource[idx] is bottle:
# 根据载架布局计算行列坐标
# ItemizedCarrier 默认是列优先布局 (A1,B1,C1,D1, A2,B2,C2,D2...)
col_idx = idx // resource.num_items_y # 列索引 (0-based)
row_idx = idx % resource.num_items_y # 行索引 (0-based)
site = {"x": col_idx, "y": row_idx, "z": 0, "identifier": str(idx)}
logger.debug(f" 🔧 [坐标修正-方法2] {bottle.name} 在索引 {idx} → 列={col_idx}, 行={row_idx}")
break
except Exception as e:
logger.error(f" ❌ [坐标计算失败] {bottle.name}: {e}")
# 最后的兜底:使用 (0,0)
site = {"x": 0, "y": 0, "z": 0, "identifier": ""}
else:
site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1, "identifier": ""}
# 获取子物料的类型映射
bottle_type_info = type_mapping.get(bottle.model)
if not bottle_type_info:
logger.error(f"❌ [PLR→Bioyond] 子物料 '{bottle.name}' 的 model '{bottle.model}' 不在 type_mapping 中")
raise ValueError(f"子物料 model '{bottle.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置")
# ⚠️ 坐标系转换说明:
# _parse_identifier_to_indices 返回: x=列索引, y=行索引 (0-based)
# Bioyond 系统要求: x=行号, y=列号 (1-based)
# 因此需要交换 x 和 y!
bioyond_x = site["y"] + 1 # 行索引 → Bioyond的x (行号)
bioyond_y = site["x"] + 1 # 列索引 → Bioyond的y (列号)
# 🐛 调试日志
logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})")
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀
# tracker.liquids 格式: [(物料名称, 数量), ...]
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶"
if hasattr(bottle, "tracker") and bottle.tracker.liquids:
# 如果有液体,使用液体的名称
first_liquid_name = bottle.tracker.liquids[0][0]
# 去除PLR系统为了唯一性添加的后缀如 "_0", "_1" 等)
if "_" in first_liquid_name and first_liquid_name.split("_")[-1].isdigit():
material_name = "_".join(first_liquid_name.split("_")[:-1])
else:
material_name = first_liquid_name
logger.debug(f" 💧 [物料名称] {bottle.name} 液体: {first_liquid_name} → 转换为: {material_name}")
else:
logger.debug(f" 📭 [物料名称] {bottle.name} 无液体,使用类型名: {material_name}")
detail_item = {
"typeId": bottle_type_info[1],
"code": bottle.code if hasattr(bottle, "code") else "",
"name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶"
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"x": bioyond_x,
"y": bioyond_y,
"z": 1,
"unit": "微升",
"Parameters": "{}" # API 实际要求的字段(必需)
}
material["details"].append(detail_item)
else:
# 单个瓶子(非载架)类型的资源
bottle = resource[0] if hasattr(resource, "capacity") and resource.capacity > 0 else resource
# 根据 resource.model 从 type_mapping 获取正确的 typeId
type_info = type_mapping.get(resource.model)
if type_info:
type_id = type_info[1]
else:
# 如果找不到映射,记录警告并使用默认值
logger.warning(f"[PLR→Bioyond] 资源 {resource.name} 的 model '{resource.model}' 不在 type_mapping 中,使用默认烧杯类型")
type_id = "3a14196b-24f2-ca49-9081-0cab8021bf1a" # 默认使用烧杯类型
# 🔥 提取物料名称:优先使用液体名称,否则使用资源名称
material_name = resource.name if hasattr(resource, "name") else ""
if hasattr(bottle, "tracker") and bottle.tracker.liquids:
# 如果有液体,使用液体的名称
first_liquid_name = bottle.tracker.liquids[0][0]
# 去除PLR系统为了唯一性添加的后缀如 "_0", "_1" 等)
if "_" in first_liquid_name and first_liquid_name.split("_")[-1].isdigit():
material_name = "_".join(first_liquid_name.split("_")[:-1])
else:
material_name = first_liquid_name
logger.debug(f" 💧 [单瓶物料] {resource.name} 液体: {first_liquid_name} → 转换为: {material_name}")
else:
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
# 🎯 处理物料默认参数和单位
# 检查是否有该物料名称的默认参数配置
default_unit = "" # 默认单位
material_parameters = {}
if material_name in material_params:
params_config = material_params[material_name].copy()
# 提取 unit 字段(如果有)
if "unit" in params_config:
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
# 剩余的字段放入 Parameters
material_parameters = params_config
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
# 转换为 JSON 字符串
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
material = {
"typeId": type_id,
"code": "",
"barCode": "",
"name": material_name, # 使用物料名称而不是资源名称
"unit": default_unit, # 使用配置的单位或默认单位
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"Parameters": parameters_json # API 实际要求的字段(必需)
}
# ⭐ 处理 locations 信息
# 优先级: update_resource_site (位置更新请求) > 当前 parent 位置
extra_info = getattr(resource, "unilabos_extra", {})
update_site = extra_info.get("update_resource_site")
if update_site:
# 情况1: 有明确的位置更新请求 (如从 A02 移动到 A03)
# 需要从 warehouse_mapping 中查找目标库位的 UUID
logger.debug(f"🔄 [PLR→Bioyond] 检测到位置更新请求: {resource.name}{update_site}")
# 遍历所有仓库查找目标库位
target_warehouse_name = None
target_location_uuid = None
for warehouse_name, warehouse_info in warehouse_mapping.items():
site_uuids = warehouse_info.get("site_uuids", {})
if update_site in site_uuids:
target_warehouse_name = warehouse_name
target_location_uuid = site_uuids[update_site]
break
if target_warehouse_name and target_location_uuid:
# 从库位代码解析坐标 (如 "A03" -> x=1, y=3)
# A=1, B=2, C=3, D=4...
# 01=1, 02=2, 03=3...
try:
row_letter = update_site[0] # 'A', 'B', 'C', 'D'
col_number = int(update_site[1:]) # '01', '02', '03'...
bioyond_x = ord(row_letter) - ord('A') + 1 # A→1, B→2, C→3, D→4
bioyond_y = col_number # 01→1, 02→2, 03→3
material["locations"] = [
{
"id": target_location_uuid,
"whid": warehouse_mapping[target_warehouse_name].get("uuid", ""),
"whName": target_warehouse_name,
"x": bioyond_x,
"y": bioyond_y,
"z": 1,
"quantity": 0
}
]
logger.debug(f"✅ [PLR→Bioyond] 位置更新: {resource.name}{target_warehouse_name}/{update_site} (x={bioyond_x}, y={bioyond_y})")
except Exception as e:
logger.error(f"❌ [PLR→Bioyond] 解析库位代码失败: {update_site}, 错误: {e}")
else:
logger.warning(f"⚠️ [PLR→Bioyond] 未找到库位 {update_site} 的配置")
elif resource.parent is not None and isinstance(resource.parent, ItemizedCarrier):
# 情况2: 使用当前 parent 位置
site_in_parent = resource.parent.get_child_identifier(resource)
# ⚠️ 坐标系转换说明:
# get_child_identifier 返回: x_idx=列索引, y_idx=行索引 (0-based)
# Bioyond 系统要求: x=行号, y=列号 (1-based)
# 因此需要交换 x 和 y!
bioyond_x = site_in_parent["y"] + 1 # 行索引 → Bioyond的x (行号)
bioyond_y = site_in_parent["x"] + 1 # 列索引 → Bioyond的y (列号)
material["locations"] = [
{
"id": warehouse_mapping[resource.parent.name]["site_uuids"][site_in_parent["identifier"]],
"whid": warehouse_mapping[resource.parent.name]["uuid"],
"whName": resource.parent.name,
"x": site_in_parent["z"] + 1,
"y": site_in_parent["y"] + 1,
"x": bioyond_x,
"y": bioyond_y,
"z": 1,
"quantity": 0
}
],
]
logger.debug(f"🔄 [PLR→Bioyond] 坐标转换: {resource.name}{resource.parent.name}[{site_in_parent['identifier']}] → UniLab(列={site_in_parent['x']},行={site_in_parent['y']}) → Bioyond(x={bioyond_x},y={bioyond_y})")
print(f"material_data: {material}")
bioyond_materials.append(material)
return bioyond_materials

View File

@@ -146,7 +146,7 @@ class ItemizedCarrier(ResourcePLR):
if site_location == location:
idx = i
break
if not reassign and self.sites[idx] is not None:
raise ValueError(f"a site with index {idx} already exists")
super().assign_child_resource(resource, location=location, reassign=reassign)
@@ -172,18 +172,18 @@ class ItemizedCarrier(ResourcePLR):
def get_child_identifier(self, child: ResourcePLR):
"""Get the identifier information for a given child resource.
Args:
child: The Resource object to find the identifier for
Returns:
dict: A dictionary containing:
- identifier: The string identifier (e.g. "A1", "B2")
- idx: The integer index in the sites list
- x: The x index (column index, 0-based)
- y: The y index (row index, 0-based)
- y: The y index (row index, 0-based)
- z: The z index (layer index, 0-based)
Raises:
ValueError: If the child resource is not found in this carrier
"""
@@ -192,10 +192,10 @@ class ItemizedCarrier(ResourcePLR):
if resource is child:
# Get the identifier from ordering keys
identifier = list(self._ordering.keys())[idx]
# Parse identifier to get x, y, z indices
x_idx, y_idx, z_idx = self._parse_identifier_to_indices(identifier, idx)
return {
"identifier": identifier,
"idx": idx,
@@ -203,17 +203,17 @@ class ItemizedCarrier(ResourcePLR):
"y": y_idx,
"z": z_idx
}
# If not found, raise an error
raise ValueError(f"Resource {child} is not assigned to this carrier")
def _parse_identifier_to_indices(self, identifier: str, idx: int) -> Tuple[int, int, int]:
"""Parse identifier string to get x, y, z indices.
Args:
identifier: String identifier like "A1", "B2", etc.
idx: Linear index as fallback for calculation
Returns:
Tuple of (x_idx, y_idx, z_idx)
"""
@@ -225,31 +225,31 @@ class ItemizedCarrier(ResourcePLR):
y_idx = remaining // self.num_items_x
x_idx = remaining % self.num_items_x
return x_idx, y_idx, z_idx
# Fallback: parse from Excel-style identifier
if isinstance(identifier, str) and len(identifier) >= 2:
# Extract row (letter) and column (number)
row_letters = ""
col_numbers = ""
for char in identifier:
if char.isalpha():
row_letters += char
elif char.isdigit():
col_numbers += char
if row_letters and col_numbers:
# Convert letter(s) to row index (A=0, B=1, etc.)
y_idx = 0
for char in row_letters:
y_idx = y_idx * 26 + (ord(char.upper()) - ord('A'))
# Convert number to column index (1-based to 0-based)
x_idx = int(col_numbers) - 1
z_idx = 0 # Default layer
return x_idx, y_idx, z_idx
# If all else fails, assume linear arrangement
return idx, 0, 0
@@ -413,8 +413,8 @@ class ItemizedCarrier(ResourcePLR):
"sites": [{
"label": str(identifier),
"visible": False if identifier in self.invisible_slots else True,
"occupied_by": self[identifier].name
if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else
"occupied_by": self[identifier].name
if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else
self[identifier] if isinstance(self[identifier], str) else None,
"position": {"x": location.x, "y": location.y, "z": location.z},
"size": self.child_size[identifier],

View File

@@ -23,16 +23,26 @@ def warehouse_factory(
empty: bool = False,
category: str = "warehouse",
model: Optional[str] = None,
col_offset: int = 0, # 新增:列起始偏移量用于生成A05-D08等命名
col_offset: int = 0, # 列起始偏移量用于生成A05-D08等命名
layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先
):
# 创建16个板架位 (4层 x 4位置)
# 创建位置坐标
locations = []
for layer in range(num_items_z): # 4层
for row in range(num_items_y): # 4行
for col in range(num_items_x): # 1列 (每层4x1=4个位置)
for layer in range(num_items_z): #
for row in range(num_items_y): #
for col in range(num_items_x): # 列
# 计算位置
x = dx + col * item_dx
y = dy + (num_items_y - row - 1) * item_dy
# 根据 layout 决定 y 坐标计算
if layout == "row-major":
# 行优先row=0(A行) 应该显示在上方,需要较小的 y 值
y = dy + row * item_dy
else:
# 列优先保持原逻辑row=0 对应较大的 y
y = dy + (num_items_y - row - 1) * item_dy
z = dz + (num_items_z - layer - 1) * item_dz
locations.append(Coordinate(x, y, z))
if removed_positions:
@@ -45,9 +55,17 @@ def warehouse_factory(
name_prefix=name,
)
len_x, len_y = (num_items_x, num_items_y) if num_items_z == 1 else (num_items_y, num_items_z) if num_items_x == 1 else (num_items_x, num_items_z)
# 应用列偏移量支持A05-D08等命名
# 使用列优先顺序生成keys (与Bioyond坐标系统一致): A01,B01,C01,D01, A02,B02,C02,D02, ...
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
# 根据 layout 参数生成不同的排序方式
# 注意:物理位置的 y 坐标是倒序的 (row=0 时 y 最大,对应前端显示的顶部)
if layout == "row-major":
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
# locations[0] 对应 row=0, y最大前端顶部→ 应该是 A01
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
else:
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
sites = {i: site for i, site in zip(keys, _sites.values())}
return WareHouse(
@@ -58,6 +76,7 @@ def warehouse_factory(
num_items_x = num_items_x,
num_items_y = num_items_y,
num_items_z = num_items_z,
ordering_layout=layout, # 传递排序方式到 ordering_layout
# ordered_items=ordered_items,
# ordering=ordering,
sites=sites,
@@ -81,8 +100,9 @@ class WareHouse(ItemizedCarrier):
sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None,
category: str = "warehouse",
model: Optional[str] = None,
ordering_layout: str = "col-major",
**kwargs
):
super().__init__(
name=name,
size_x=size_x,
@@ -99,6 +119,16 @@ class WareHouse(ItemizedCarrier):
model=model,
)
# 保存排序方式供graphio.py的坐标映射使用
# 使用独立属性避免与父类的layout冲突
self.ordering_layout = ordering_layout
def serialize(self) -> dict:
"""序列化时保存 ordering_layout 属性"""
data = super().serialize()
data['ordering_layout'] = self.ordering_layout
return data
def get_site_by_layer_position(self, row: int, col: int, layer: int) -> ResourceHolder:
if not (0 <= layer < 4 and 0 <= row < 4 and 0 <= col < 1):
raise ValueError("无效的位置: layer={}, row={}, col={}".format(layer, row, col))

View File

@@ -5,7 +5,6 @@ import json
import threading
import time
import traceback
import uuid
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union
from concurrent.futures import ThreadPoolExecutor
@@ -39,7 +38,6 @@ from unilabos.ros.msgs.message_converter import (
)
from unilabos_msgs.srv import (
ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
@@ -49,7 +47,8 @@ from unilabos_msgs.msg import Resource # type: ignore
from unilabos.ros.nodes.resource_tracker import (
DeviceNodeResourceTracker,
ResourceTreeSet, ResourceTreeInstance,
ResourceTreeSet,
ResourceTreeInstance,
)
from unilabos.ros.x.rclpyx import get_event_loop
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
@@ -340,10 +339,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
"resource_get": self.create_client(SerialCommand, "/resources/get", callback_group=self.callback_group),
"resource_delete": self.create_client(ResourceDelete, "/resources/delete", callback_group=self.callback_group),
"resource_update": self.create_client(ResourceUpdate, "/resources/update", callback_group=self.callback_group),
"resource_delete": self.create_client(
ResourceDelete, "/resources/delete", callback_group=self.callback_group
),
"resource_update": self.create_client(
ResourceUpdate, "/resources/update", callback_group=self.callback_group
),
"resource_list": self.create_client(ResourceList, "/resources/list", callback_group=self.callback_group),
"c2s_update_resource_tree": self.create_client(SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group),
"c2s_update_resource_tree": self.create_client(
SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group
),
}
def re_register_device(req, res):
@@ -583,7 +588,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().error(traceback.format_exc())
self.lab_logger().debug(f"资源更新结果: {response}")
def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]):
def transfer_to_new_resource(
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
):
parent_uuid = tree.root_node.res_content.parent_uuid
if parent_uuid:
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
@@ -610,16 +617,26 @@ class BaseROS2DeviceNode(Node, Generic[T]):
old_parent = plr_resource.parent
if old_parent is not None:
# plr并不支持同一个deck的加载和卸载
self.lab_logger().warning(
f"物料{plr_resource}请求从{old_parent}卸载"
)
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
old_parent.unassign_child_resource(plr_resource)
self.lab_logger().warning(
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
)
parent_resource.assign_child_resource(
plr_resource, location=None, **additional_params
)
# ⭐ assign 之前,需要从 resources 列表中移除
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
# 如果不移除figure_resource 会找到两次:一次在 resources一次在 parent 的 children
resource_id = id(plr_resource)
for i, r in enumerate(self.resource_tracker.resources):
if id(r) == resource_id:
self.resource_tracker.resources.pop(i)
self.lab_logger().debug(
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
)
break
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
func = getattr(self.driver_instance, "resource_tree_transfer", None)
if callable(func):
# 分别是 物料的原来父节点当前物料的状态物料的新父节点此时物料已经重新assign了
@@ -1080,7 +1097,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
queried_resources = []
for resource_data in resource_inputs:
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "uuid": resource_data.get("uuid", None), "with_children": True})
r.command = json.dumps(
{
"id": resource_data["id"],
"uuid": resource_data.get("uuid", None),
"with_children": True,
}
)
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients[
"resource_get"