Fix/resource UUID and doc fix (#109)

* Fix ResourceTreeSet load error

* Raise error when using unsupported type to create ResourceTreeSet

* Fix children key error

* Fix children key error

* Fix workstation resource not tracking

* Fix workstation deck & children resource dupe

* Fix workstation deck & children resource dupe

* Fix multiple resource error

* Fix resource tree update

* Fix resource tree update

* Force confirm uuid

* Tip more error log

* Refactor Bioyond workstation and experiment workflow (#105)

Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods.

* Fix resource get.
Fix resource parent not found.
Mapping uuid for all resources.

* mount parent uuid

* Add logging configuration based on BasicConfig in main function

* fix workstation node error

* fix workstation node error

* Update boot example

* temp fix for resource get

* temp fix for resource get

* provide error info when cant find plr type

* pack repo info

* fix to plr type error

* fix to plr type error

* Update regular container method

* support no size init

* fix comprehensive_station.json

* fix comprehensive_station.json

* fix type conversion

* fix state loading for regular container

* Update deploy-docs.yml

* Update deploy-docs.yml

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
This commit is contained in:
Xuwznln
2025-10-16 17:26:07 +08:00
committed by GitHub
parent c85c49817d
commit 7c440d10ab
24 changed files with 1072 additions and 447 deletions

View File

@@ -242,6 +242,10 @@ jobs:
echo Adding: verify_installation.py
copy scripts\verify_installation.py dist-package\
rem Copy source code repository (including .git)
echo Adding: Uni-Lab-OS source repository
robocopy . dist-package\Uni-Lab-OS /E /XD dist-package /NFL /NDL /NJH /NJS /NC /NS || if %ERRORLEVEL% LSS 8 exit /b 0
rem Create README using Python script
echo Creating: README.txt
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
@@ -274,6 +278,10 @@ jobs:
echo "Adding: verify_installation.py"
cp scripts/verify_installation.py dist-package/
# Copy source code repository (including .git)
echo "Adding: Uni-Lab-OS source repository"
rsync -a --exclude='dist-package' . dist-package/Uni-Lab-OS
# Create README using Python script
echo "Creating: README.txt"
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt

View File

@@ -39,24 +39,39 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.ref }}
fetch-depth: 0
- name: Setup Python environment
uses: actions/setup-python@v5
- name: Setup Miniforge (with mamba)
uses: conda-incubator/setup-miniconda@v3
with:
python-version: '3.10'
miniforge-version: latest
use-mamba: true
python-version: '3.11.11'
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: flexible
activate-environment: unilab
auto-update-conda: false
show-channel-urls: true
- name: Install system dependencies
- name: Install unilabos and dependencies
run: |
sudo apt-get update
sudo apt-get install -y pandoc
echo "Installing unilabos and dependencies to unilab environment..."
echo "Using mamba for faster and more reliable dependency resolution..."
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
- name: Install Python dependencies
- name: Install latest unilabos from source
run: |
python -m pip install --upgrade pip
# Install package in development mode to get version info
pip install -e .
# Install documentation dependencies
pip install -r docs/requirements.txt
echo "Uninstalling existing unilabos..."
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
echo "Installing unilabos from source..."
mamba run -n unilab pip install .
echo "Verifying installation..."
mamba run -n unilab pip show unilabos
- name: Install documentation dependencies
run: |
echo "Installing documentation build dependencies..."
mamba run -n unilab pip install -r docs/requirements.txt
- name: Setup Pages
id: pages
@@ -68,8 +83,8 @@ jobs:
cd docs
# Clean previous builds
rm -rf _build
# Build HTML documentation
python -m sphinx -b html . _build/html -v
# Build HTML documentation in conda environment
mamba run -n unilab python -m sphinx -b html . _build/html -v
- name: Check build results
run: |

View File

@@ -91,7 +91,7 @@
使用以下命令启动模拟反应器:
```bash
unilab -g test/experiments/mock_reactor.json --app_bridges ""
unilab -g test/experiments/mock_reactor.json
```
### 2. 执行抽真空和充气操作

View File

@@ -23,7 +23,7 @@ extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
"sphinx_rtd_theme"
"sphinx_rtd_theme",
]
source_suffix = {

View File

@@ -170,15 +170,16 @@
"z": 0
},
"config": {
"max_volume": 1000.0
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 200,
"size_y": 150,
"size_z": 0
},
"data": {
"liquids": [
{
"liquid_type": "DMF",
"liquid_volume": 1000.0
}
]
"liquids": [["DMF", 500.0]],
"pending_liquids": [["DMF", 500.0]]
}
},
{
@@ -194,15 +195,16 @@
"z": 0
},
"config": {
"max_volume": 1000.0
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 200,
"size_y": 150,
"size_z": 0
},
"data": {
"liquids": [
{
"liquid_type": "ethyl_acetate",
"liquid_volume": 1000.0
}
]
"liquids": [["ethyl_acetate", 1000.0]],
"pending_liquids": [["ethyl_acetate", 1000.0]]
}
},
{
@@ -218,15 +220,16 @@
"z": 0
},
"config": {
"max_volume": 1000.0
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 300,
"size_y": 150,
"size_z": 0
},
"data": {
"liquids": [
{
"liquid_type": "hexane",
"liquid_volume": 1000.0
}
]
"liquids": [["hexane", 1000.0]],
"pending_liquids": [["hexane", 1000.0]]
}
},
{
@@ -242,15 +245,16 @@
"z": 0
},
"config": {
"max_volume": 1000.0
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 900,
"size_y": 150,
"size_z": 0
},
"data": {
"liquids": [
{
"liquid_type": "methanol",
"liquid_volume": 1000.0
}
]
"liquids": [["methanol", 1000.0]],
"pending_liquids": [["methanol", 1000.0]]
}
},
{
@@ -266,15 +270,16 @@
"z": 0
},
"config": {
"max_volume": 1000.0
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 950,
"size_y": 150,
"size_z": 0
},
"data": {
"liquids": [
{
"liquid_type": "water",
"liquid_volume": 1000.0
}
]
"liquids": [["water", 1000.0]],
"pending_liquids": [["water", 1000.0]]
}
},
{
@@ -335,14 +340,16 @@
},
"config": {
"max_volume": 500.0,
"type": "RegularContainer",
"category": "container",
"max_temp": 200.0,
"min_temp": -20.0,
"has_stirrer": true,
"has_heater": true
},
"data": {
"liquids": [
]
"liquids": [],
"pending_liquids": []
}
},
{
@@ -419,11 +426,16 @@
"z": 0
},
"config": {
"max_volume": 2000.0
"max_volume": 2000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 500,
"size_y": 400,
"size_z": 0
},
"data": {
"liquids": [
]
"liquids": [],
"pending_liquids": []
}
},
{
@@ -439,11 +451,16 @@
"z": 0
},
"config": {
"max_volume": 2000.0
"max_volume": 2000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 1100,
"size_y": 500,
"size_z": 0
},
"data": {
"liquids": [
]
"liquids": [],
"pending_liquids": []
}
},
{
@@ -649,11 +666,16 @@
"z": 0
},
"config": {
"max_volume": 250.0
"max_volume": 250.0,
"type": "RegularContainer",
"category": "container",
"size_x": 900,
"size_y": 500,
"size_z": 0
},
"data": {
"liquids": [
]
"liquids": [],
"pending_liquids": []
}
},
{
@@ -669,11 +691,16 @@
"z": 0
},
"config": {
"max_volume": 250.0
"max_volume": 250.0,
"type": "RegularContainer",
"category": "container",
"size_x": 950,
"size_y": 500,
"size_z": 0
},
"data": {
"liquids": [
]
"liquids": [],
"pending_liquids": []
}
},
{
@@ -689,11 +716,16 @@
"z": 0
},
"config": {
"max_volume": 250.0
"max_volume": 250.0,
"type": "RegularContainer",
"category": "container",
"size_x": 1050,
"size_y": 500,
"size_z": 0
},
"data": {
"liquids": [
]
"liquids": [],
"pending_liquids": []
}
},
{
@@ -733,6 +765,11 @@
},
"config": {
"max_volume": 500.0,
"size_x": 550,
"size_y": 250,
"size_z": 0,
"type": "RegularContainer",
"category": "container",
"reagent": "sodium_chloride",
"physical_state": "solid"
},
@@ -756,6 +793,11 @@
},
"config": {
"volume": 500.0,
"size_x": 600,
"size_y": 250,
"size_z": 0,
"type": "RegularContainer",
"category": "container",
"reagent": "sodium_carbonate",
"physical_state": "solid"
},
@@ -779,6 +821,11 @@
},
"config": {
"volume": 500.0,
"size_x": 650,
"size_y": 250,
"size_z": 0,
"type": "RegularContainer",
"category": "container",
"reagent": "magnesium_chloride",
"physical_state": "solid"
},

View File

@@ -8,7 +8,7 @@
],
"parent": null,
"type": "device",
"class": "dispensing_station.bioyond",
"class": "workstation.bioyond_dispensing_station",
"config": {
"config": {
"api_key": "DE9BDDA0",
@@ -20,13 +20,6 @@
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
}
},
"station_config": {
"station_type": "dispensing_station",
"enable_dispensing_station": true,
"enable_reaction_station": false,
"station_name": "DispensingStation_001",
"description": "Bioyond配液工作站"
},
"protocol_type": []
},
"data": {}

View File

@@ -24,9 +24,13 @@
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
"烧杯": ["BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"],
"试剂瓶": ["BIOYOND_PolymerStation_1BottleCarrier", ""],
"样品板": ["BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"],
"分装板": ["BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"],
"样品瓶": ["BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"],
"90%分装小瓶": ["BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"],
"10%分装小瓶": ["BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"]
}
},
"deck": {
@@ -42,7 +46,6 @@
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",

View File

@@ -180,6 +180,7 @@ def main():
working_dir = os.path.abspath(os.getcwd())
else:
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
if args_dict.get("working_dir"):
working_dir = args_dict.get("working_dir", "")
if config_path and not os.path.exists(config_path):
@@ -211,6 +212,14 @@ def main():
# 加载配置文件
print_status(f"当前工作目录为 {working_dir}", "info")
load_config_from_file(config_path)
# 根据配置重新设置日志级别
from unilabos.utils.log import configure_logger, logger
if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
configure_logger(loglevel=BasicConfig.log_level)
if args_dict["addr"] == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"

View File

@@ -73,6 +73,8 @@ class HTTPClient:
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
if not self.initialized or first_add:
@@ -92,6 +94,8 @@ class HTTPClient:
timeout=100,
)
with open(os.path.join(BasicConfig.working_dir, "res_resource_tree_add.json"), "w", encoding="utf-8") as f:
f.write(f"{response.status_code}" + "\n" + response.text)
# 处理响应构建UUID映射
uuid_mapping = {}
if response.status_code == 200:

View File

@@ -2,7 +2,7 @@ import base64
import traceback
import os
import importlib.util
from typing import Optional
from typing import Optional, Literal
from unilabos.utils import logger
@@ -18,6 +18,7 @@ class BasicConfig:
vis_2d_enable = False
enable_resource_load = True
communication_protocol = "websocket"
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
@classmethod
def auth_secret(cls):

View File

@@ -6,8 +6,15 @@ from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstati
class BioyondDispensingStation(BioyondWorkstation):
def __init__(self, config):
super().__init__(config)
def __init__(
self,
config,
# 桌子
deck,
*args,
**kwargs,
):
super().__init__(config, deck, *args, **kwargs)
# self.config = config
# self.api_key = config["api_key"]
# self.host = config["api_host"]

View File

@@ -1,11 +1,10 @@
# experiment_workflow.py
"""
实验流程主程序
"""
import json
from bioyond_rpc import BioyondV1RPC
from config import API_CONFIG, WORKFLOW_MAPPINGS
from reaction_station import BioyondReactionStation
from config import API_CONFIG, WORKFLOW_MAPPINGS, DECK_CONFIG, MATERIAL_TYPE_MAPPINGS
def run_experiment():
@@ -14,15 +13,20 @@ def run_experiment():
# 初始化Bioyond客户端
config = {
**API_CONFIG,
"workflow_mappings": WORKFLOW_MAPPINGS
"workflow_mappings": WORKFLOW_MAPPINGS,
"material_type_mappings": MATERIAL_TYPE_MAPPINGS
}
Bioyond = BioyondV1RPC(config)
# 创建BioyondReactionStation实例传入deck配置
Bioyond = BioyondReactionStation(
config=config,
deck=DECK_CONFIG
)
print("\n============= 多工作流参数测试(简化接口+材料缓存)=============")
# 显示可用的材料名称前20个
available_materials = Bioyond.get_available_materials()
available_materials = Bioyond.hardware_interface.get_available_materials()
print(f"可用材料名称前20个: {available_materials[:20]}")
print(f"总共有 {len(available_materials)} 个材料可用\n")
@@ -84,32 +88,32 @@ def run_experiment():
material_id="3",
time="180",
torque_variation="2",
assign_material_name="BTDA-1",
assign_material_name="BTDA1",
temperature=-10.00
)
#二杆样品版90
print("7. 添加固体进料小瓶,带参数...")
Bioyond.solid_feeding_vials(
material_id="3",
time="180",
torque_variation="2",
assign_material_name="BTDA-2",
assign_material_name="BTDA2",
temperature=25.00
)
#二杆样品版90
print("8. 添加固体进料小瓶,带参数...")
Bioyond.solid_feeding_vials(
material_id="3",
time="480",
torque_variation="2",
assign_material_name="BTDA-3",
assign_material_name="BTDA3",
temperature=25.00
)
# 液体投料滴定(第一个)
print("9. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="1000",
volume_formula="{{6-0-5}}+{{7-0-5}}+{{8-0-5}}",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
@@ -169,8 +173,6 @@ def run_experiment():
temperature="25.00"
)
print("15. 添加液体投料溶剂,带参数...")
Bioyond.liquid_feeding_solvents(
assign_material_name="PGME",
@@ -194,8 +196,8 @@ def run_experiment():
print("\n4. 执行process_and_execute_workflow...")
result = Bioyond.process_and_execute_workflow(
workflow_name="test3_86",
task_name="实验3_86"
workflow_name="test3_8",
task_name="实验3_8"
)
# 显示执行结果

View File

@@ -1,30 +1,67 @@
import json
from typing import List, Dict, Any
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
from unilabos.devices.workstation.bioyond_studio.config import (
API_CONFIG, WORKFLOW_MAPPINGS, WORKFLOW_STEP_IDS, MATERIAL_TYPE_MAPPINGS,
STATION_TYPES, DEFAULT_STATION_CONFIG
WORKFLOW_STEP_IDS,
WORKFLOW_TO_SECTION_MAP
)
class BioyondReactionStation(BioyondWorkstation):
def __init__(self, config: dict = None):
super().__init__(config)
"""Bioyond反应站类
继承自BioyondWorkstation,提供反应站特定的业务方法
"""
def __init__(self, config: dict = None, deck=None):
"""初始化反应站
Args:
config: 配置字典,应包含workflow_mappings等配置
deck: Deck对象
"""
# 如果 deck 作为独立参数传入,使用它;否则尝试从 config 中提取
if deck is None and config:
deck = config.get('deck')
# 调试信息:检查传入的config
print(f"BioyondReactionStation初始化 - config包含workflow_mappings: {'workflow_mappings' in (config or {})}")
if config and 'workflow_mappings' in config:
print(f"workflow_mappings内容: {config['workflow_mappings']}")
# 将 config 作为 bioyond_config 传递给父类
super().__init__(bioyond_config=config, deck=deck)
# 调试信息:检查初始化后的workflow_mappings
print(f"BioyondReactionStation初始化完成 - workflow_mappings: {self.workflow_mappings}")
print(f"workflow_mappings长度: {len(self.workflow_mappings)}")
# ==================== 工作流方法 ====================
# 工作流方法
def reactor_taken_out(self):
"""反应器取出"""
self.hardware_interface.append_to_workflow_sequence('{"web_workflow_name": "reactor_taken_out"}')
self.append_to_workflow_sequence('{"web_workflow_name": "reactor_taken_out"}')
reactor_taken_out_params = {"param_values": {}}
self.hardware_interface.pending_task_params.append(reactor_taken_out_params)
self.pending_task_params.append(reactor_taken_out_params)
print(f"成功添加反应器取出工作流")
print(f"当前队列长度: {len(self.hardware_interface.pending_task_params)}")
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def reactor_taken_in(self, assign_material_name: str, cutoff: str = "900000", temperature: float = -10.00):
"""反应器放入"""
def reactor_taken_in(
self,
assign_material_name: str,
cutoff: str = "900000",
temperature: float = -10.00
):
"""反应器放入
Args:
assign_material_name: 物料名称
cutoff: 截止参数
temperature: 温度
"""
self.append_to_workflow_sequence('{"web_workflow_name": "reactor_taken_in"}')
material_id = self._get_material_id_by_name(assign_material_name)
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
if isinstance(temperature, str):
temperature = float(temperature)
@@ -45,11 +82,25 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def solid_feeding_vials(self, material_id: str, time: str = "0", torque_variation: str = "1",
assign_material_name: str = None, temperature: float = 25.00):
"""固体进料小瓶"""
def solid_feeding_vials(
self,
material_id: str,
time: str = "0",
torque_variation: str = "1",
assign_material_name: str = None,
temperature: float = 25.00
):
"""固体进料小瓶
Args:
material_id: 物料ID
time: 时间
torque_variation: 扭矩变化
assign_material_name: 物料名称
temperature: 温度
"""
self.append_to_workflow_sequence('{"web_workflow_name": "Solid_feeding_vials"}')
material_id_m = self._get_material_id_by_name(assign_material_name)
material_id_m = self.hardware_interface._get_material_id_by_name(assign_material_name)
if isinstance(temperature, str):
temperature = float(temperature)
@@ -76,12 +127,27 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def liquid_feeding_vials_non_titration(self, volumeFormula: str, assign_material_name: str,
titration_type: str = "1", time: str = "0",
torque_variation: str = "1", temperature: float = 25.00):
"""液体进料小瓶(非滴定)"""
def liquid_feeding_vials_non_titration(
self,
volumeFormula: str,
assign_material_name: str,
titration_type: str = "1",
time: str = "0",
torque_variation: str = "1",
temperature: float = 25.00
):
"""液体进料小瓶(非滴定)
Args:
volumeFormula: 体积公式
assign_material_name: 物料名称
titration_type: 滴定类型
time: 时间
torque_variation: 扭矩变化
temperature: 温度
"""
self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding_vials(non-titration)"}')
material_id = self._get_material_id_by_name(assign_material_name)
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
if isinstance(temperature, str):
temperature = float(temperature)
@@ -109,11 +175,27 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def liquid_feeding_solvents(self, assign_material_name: str, volume: str, titration_type: str = "1",
time: str = "360", torque_variation: str = "2", temperature: float = 25.00):
"""液体进料溶剂"""
def liquid_feeding_solvents(
self,
assign_material_name: str,
volume: str,
titration_type: str = "1",
time: str = "360",
torque_variation: str = "2",
temperature: float = 25.00
):
"""液体进料溶剂
Args:
assign_material_name: 物料名称
volume: 体积
titration_type: 滴定类型
time: 时间
torque_variation: 扭矩变化
temperature: 温度
"""
self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding_solvents"}')
material_id = self._get_material_id_by_name(assign_material_name)
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
if isinstance(temperature, str):
temperature = float(temperature)
@@ -141,11 +223,27 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def liquid_feeding_titration(self, volume_formula: str, assign_material_name: str, titration_type: str = "1",
time: str = "90", torque_variation: int = 2, temperature: float = 25.00):
"""液体进料(滴定)"""
def liquid_feeding_titration(
self,
volume_formula: str,
assign_material_name: str,
titration_type: str = "1",
time: str = "90",
torque_variation: int = 2,
temperature: float = 25.00
):
"""液体进料(滴定)
Args:
volume_formula: 体积公式
assign_material_name: 物料名称
titration_type: 滴定类型
time: 时间
torque_variation: 扭矩变化
temperature: 温度
"""
self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding(titration)"}')
material_id = self._get_material_id_by_name(assign_material_name)
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
if isinstance(temperature, str):
temperature = float(temperature)
@@ -173,12 +271,27 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def liquid_feeding_beaker(self, volume: str = "35000", assign_material_name: str = "BAPP",
time: str = "0", torque_variation: str = "1", titrationType: str = "1",
temperature: float = 25.00):
"""液体进料烧杯"""
def liquid_feeding_beaker(
self,
volume: str = "35000",
assign_material_name: str = "BAPP",
time: str = "0",
torque_variation: str = "1",
titrationType: str = "1",
temperature: float = 25.00
):
"""液体进料烧杯
Args:
volume: 体积
assign_material_name: 物料名称
time: 时间
torque_variation: 扭矩变化
titrationType: 滴定类型
temperature: 温度
"""
self.append_to_workflow_sequence('{"web_workflow_name": "liquid_feeding_beaker"}')
material_id = self._get_material_id_by_name(assign_material_name)
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
if isinstance(temperature, str):
temperature = float(temperature)
@@ -205,3 +318,322 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"成功添加液体进料烧杯参数: volume={volume}μL, material={assign_material_name}->ID:{material_id}")
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
# ==================== 工作流管理方法 ====================
def get_workflow_sequence(self) -> List[str]:
"""获取当前工作流执行顺序
Returns:
工作流名称列表
"""
id_to_name = {workflow_id: name for name, workflow_id in self.workflow_mappings.items()}
workflow_names = []
for workflow_id in self.workflow_sequence:
workflow_names.append(id_to_name.get(workflow_id, workflow_id))
return workflow_names
def workflow_step_query(self, workflow_id: str) -> dict:
"""查询工作流步骤参数
Args:
workflow_id: 工作流ID
Returns:
工作流步骤参数字典
"""
return self.hardware_interface.workflow_step_query(workflow_id)
def create_order(self, json_str: str) -> dict:
"""创建订单
Args:
json_str: 订单参数的JSON字符串
Returns:
创建结果
"""
return self.hardware_interface.create_order(json_str)
# ==================== 工作流执行核心方法 ====================
# 发布任务
def process_and_execute_workflow(self, workflow_name: str, task_name: str) -> dict:
web_workflow_list = self.get_workflow_sequence()
workflow_name = workflow_name
pending_params_backup = self.pending_task_params.copy()
print(f"保存pending_task_params副本{len(pending_params_backup)}个参数")
# 1. 处理网页工作流列表
print(f"处理网页工作流列表: {web_workflow_list}")
web_workflow_json = json.dumps({"web_workflow_list": web_workflow_list})
workflows_result = self.process_web_workflows(web_workflow_json)
if not workflows_result:
error_msg = "处理网页工作流列表失败"
print(error_msg)
result = str({"success": False, "error": f"process_and_execute_workflow:{error_msg}", "method": "process_and_execute_workflow", "step": "process_web_workflows"})
return result
# 2. 合并工作流序列
print(f"合并工作流序列,名称: {workflow_name}")
merge_json = json.dumps({"name": workflow_name})
merged_workflow = self.merge_sequence_workflow(merge_json)
print(f"合并工作流序列结果: {merged_workflow}")
if not merged_workflow:
error_msg = "合并工作流序列失败"
print(error_msg)
result = str({"success": False, "error": f"process_and_execute_workflow:{error_msg}", "method": "process_and_execute_workflow", "step": "merge_sequence_workflow"})
return result
# 3. 合并所有参数并创建任务
# 新API只返回状态信息需要适配处理
if isinstance(merged_workflow, dict) and merged_workflow.get("code") == 1:
# 新API返回格式{code: 1, message: "", timestamp: 0}
# 使用传入的工作流名称和生成的临时ID
final_workflow_name = workflow_name
workflow_id = f"merged_{workflow_name}_{self.hardware_interface.get_current_time_iso8601().replace('-', '').replace('T', '').replace(':', '').replace('.', '')[:14]}"
print(f"新API合并成功使用工作流创建任务: {final_workflow_name} (临时ID: {workflow_id})")
else:
# 旧API返回格式包含详细工作流信息
final_workflow_name = merged_workflow.get("name", workflow_name)
workflow_id = merged_workflow.get("subWorkflows", [{}])[0].get("id", "")
print(f"旧API格式使用工作流创建任务: {final_workflow_name} (ID: {workflow_id})")
if not workflow_id:
error_msg = "无法获取工作流ID"
print(error_msg)
result = str({"success": False, "error": f"process_and_execute_workflow:{error_msg}", "method": "process_and_execute_workflow", "step": "get_workflow_id"})
return result
workflow_query_json = json.dumps({"workflow_id": workflow_id})
workflow_params_structure = self.workflow_step_query(workflow_query_json)
self.pending_task_params = pending_params_backup
print(f"恢复pending_task_params{len(self.pending_task_params)}个参数")
param_values = self.generate_task_param_values(workflow_params_structure)
task_params = [{
"orderCode": f"BSO{self.hardware_interface.get_current_time_iso8601().replace('-', '').replace('T', '').replace(':', '').replace('.', '')[:14]}",
"orderName": f"实验-{self.hardware_interface.get_current_time_iso8601()[:10].replace('-', '')}",
"workFlowId": workflow_id,
"borderNumber": 1,
"paramValues": param_values,
"extendProperties": ""
}]
task_json = json.dumps(task_params)
print(f"创建任务参数: {type(task_json)}")
result = self.create_order(task_json)
if not result:
error_msg = "创建任务失败"
print(error_msg)
result = str({"success": False, "error": f"process_and_execute_workflow:{error_msg}", "method": "process_and_execute_workflow", "step": "create_order"})
return result
print(f"任务创建成功: {result}")
self.pending_task_params.clear()
print("已清空pending_task_params")
return {
"success": True,
"workflow": {"name": final_workflow_name, "id": workflow_id},
"task": result,
"method": "process_and_execute_workflow"
}
def merge_sequence_workflow(self, json_str: str) -> dict:
"""合并当前工作流序列
Args:
json_str: 包含name等参数的JSON字符串
Returns:
合并结果
"""
try:
data = json.loads(json_str)
name = data.get("name", "合并工作流")
step_parameters = data.get("stepParameters", {})
variables = data.get("variables", {})
except json.JSONDecodeError:
return {}
if not self.workflow_sequence:
print("工作流序列为空,无法合并")
return {}
# 将工作流ID列表转换为新API要求的格式
workflows = [{"id": workflow_id} for workflow_id in self.workflow_sequence]
# 构建新的API参数格式
params = {
"name": name,
"workflows": workflows,
"stepParameters": step_parameters,
"variables": variables
}
# 使用新的API接口
response = self.hardware_interface.post(
url=f'{self.hardware_interface.host}/api/lims/workflow/merge-workflow-with-parameters',
params={
"apiKey": self.hardware_interface.api_key,
"requestTime": self.hardware_interface.get_current_time_iso8601(),
"data": params,
})
if not response or response['code'] != 1:
return {}
return response.get("data", {})
def generate_task_param_values(self, workflow_params_structure: dict) -> dict:
"""生成任务参数值
根据工作流参数结构和待处理的任务参数,生成最终的任务参数值
Args:
workflow_params_structure: 工作流参数结构
Returns:
任务参数值字典
"""
if not workflow_params_structure:
print("workflow_params_structure为空")
return {}
data = workflow_params_structure
# 从pending_task_params中提取实际参数值,按DisplaySectionName和Key组织
pending_params_by_section = {}
print(f"开始处理pending_task_params,共{len(self.pending_task_params)}个任务参数组")
# 获取工作流执行顺序,用于按顺序匹配参数
workflow_sequence = self.get_workflow_sequence()
print(f"工作流执行顺序: {workflow_sequence}")
workflow_index = 0
# 遍历所有待处理的任务参数
for i, task_param in enumerate(self.pending_task_params):
if 'param_values' in task_param:
print(f"处理第{i+1}个任务参数组,包含{len(task_param['param_values'])}个步骤")
if workflow_index < len(workflow_sequence):
current_workflow = workflow_sequence[workflow_index]
section_name = WORKFLOW_TO_SECTION_MAP.get(current_workflow)
print(f" 匹配到工作流: {current_workflow} -> {section_name}")
workflow_index += 1
else:
print(f" 警告: 参数组{i+1}超出了工作流序列范围")
continue
if not section_name:
print(f" 警告: 工作流{current_workflow}没有对应的DisplaySectionName")
continue
if section_name not in pending_params_by_section:
pending_params_by_section[section_name] = {}
# 处理每个步骤的参数
for step_id, param_list in task_param['param_values'].items():
print(f" 步骤ID: {step_id},参数数量: {len(param_list)}")
for param_item in param_list:
key = param_item.get('Key', '')
value = param_item.get('Value', '')
m = param_item.get('m', 0)
n = param_item.get('n', 0)
print(f" 参数: {key} = {value} (m={m}, n={n}) -> 分组到{section_name}")
param_key = f"{section_name}.{key}"
if param_key not in pending_params_by_section[section_name]:
pending_params_by_section[section_name][param_key] = []
pending_params_by_section[section_name][param_key].append({
'value': value,
'm': m,
'n': n
})
print(f"pending_params_by_section构建完成,包含{len(pending_params_by_section)}个分组")
# 收集所有参数,过滤TaskDisplayable为0的项
filtered_params = []
for step_id, step_info in data.items():
if isinstance(step_info, list):
for step_item in step_info:
param_list = step_item.get("parameterList", [])
for param in param_list:
if param.get("TaskDisplayable") == 0:
continue
param_with_step = param.copy()
param_with_step['step_id'] = step_id
param_with_step['step_name'] = step_item.get("name", "")
param_with_step['step_m'] = step_item.get("m", 0)
param_with_step['step_n'] = step_item.get("n", 0)
filtered_params.append(param_with_step)
# 按DisplaySectionIndex排序
filtered_params.sort(key=lambda x: x.get('DisplaySectionIndex', 0))
# 生成参数映射
param_mapping = {}
step_params = {}
for param in filtered_params:
step_id = param['step_id']
if step_id not in step_params:
step_params[step_id] = []
step_params[step_id].append(param)
# 为每个步骤生成参数
for step_id, params in step_params.items():
param_list = []
for param in params:
key = param.get('Key', '')
display_section_index = param.get('DisplaySectionIndex', 0)
step_m = param.get('step_m', 0)
step_n = param.get('step_n', 0)
section_name = param.get('DisplaySectionName', '')
param_key = f"{section_name}.{key}"
if section_name in pending_params_by_section and param_key in pending_params_by_section[section_name]:
pending_param_list = pending_params_by_section[section_name][param_key]
if pending_param_list:
pending_param = pending_param_list[0]
value = pending_param['value']
m = step_m
n = step_n
print(f" 匹配成功: {section_name}.{key} = {value} (m={m}, n={n})")
pending_param_list.pop(0)
else:
value = "1"
m = step_m
n = step_n
print(f" 匹配失败: {section_name}.{key},参数列表为空,使用默认值 = {value}")
else:
value = "1"
m = display_section_index
n = step_n
print(f" 匹配失败: {section_name}.{key},使用默认值 = {value} (m={m}, n={n})")
param_item = {
"m": m,
"n": n,
"key": key,
"value": str(value).strip()
}
param_list.append(param_item)
if param_list:
param_mapping[step_id] = param_list
print(f"生成任务参数值,包含 {len(param_mapping)} 个步骤")
return param_mapping

View File

@@ -129,7 +129,6 @@ class BioyondWorkstation(WorkstationBase):
self,
bioyond_config: Optional[Dict[str, Any]] = None,
deck: Optional[Any] = None,
station_config: Optional[Dict[str, Any]] = None,
*args,
**kwargs,
):
@@ -152,9 +151,6 @@ class BioyondWorkstation(WorkstationBase):
if isinstance(resource, WareHouse):
self.deck.warehouses[resource.name] = resource
# 配置站点类型
self._configure_station_type(station_config)
# 创建通信模块
self._create_communication_module(bioyond_config)
self.resource_synchronizer = BioyondResourceSynchronizer(self)
@@ -167,8 +163,6 @@ class BioyondWorkstation(WorkstationBase):
self.workflow_mappings = {}
self.workflow_sequence = []
self.pending_task_params = []
self.material_cache = {}
self._load_material_cache()
if "workflow_mappings" in bioyond_config:
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
@@ -325,10 +319,22 @@ class BioyondWorkstation(WorkstationBase):
}
def append_to_workflow_sequence(self, web_workflow_name: str) -> bool:
workflow_id = self._get_workflow(web_workflow_name)
# 检查是否为JSON格式的字符串
actual_workflow_name = web_workflow_name
if web_workflow_name.startswith('{') and web_workflow_name.endswith('}'):
try:
data = json.loads(web_workflow_name)
actual_workflow_name = data.get("web_workflow_name", web_workflow_name)
print(f"解析JSON格式工作流名称: {web_workflow_name} -> {actual_workflow_name}")
except json.JSONDecodeError:
print(f"JSON解析失败使用原始字符串: {web_workflow_name}")
workflow_id = self._get_workflow(actual_workflow_name)
if workflow_id:
self.workflow_sequence.append(workflow_id)
print(f"添加工作流到执行顺序: {web_workflow_name} -> {workflow_id}")
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
return True
return False
def set_workflow_sequence(self, json_str: str) -> List[str]:
try:

View File

@@ -171,7 +171,6 @@ class WorkstationBase(ABC):
def post_init(self, ros_node: ROS2WorkstationNode) -> None:
# 初始化物料系统
self._ros_node = ros_node
self._ros_node.update_resource([self.deck])
def _build_resource_mappings(self, deck: Deck):
"""递归构建资源映射"""

View File

@@ -3,7 +3,7 @@ container:
- container
class:
module: unilabos.resources.container:RegularContainer
type: unilabos
type: pylabrobot
description: regular organic container
handles:
- data_key: fluid_in

View File

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

View File

@@ -4,6 +4,7 @@ import json
import os.path
import traceback
from typing import Union, Any, Dict, List, Tuple
import uuid
import networkx as nx
from pylabrobot.resources import ResourceHolder
from unilabos_msgs.msg import Resource
@@ -16,6 +17,7 @@ from unilabos.ros.nodes.resource_tracker import (
ResourceDictInstance,
ResourceTreeSet,
)
from unilabos.utils import logger
from unilabos.utils.banner_print import print_status
try:
@@ -52,7 +54,7 @@ def canonicalize_nodes_data(
if not node.get("type"):
node["type"] = "device"
print_status(f"Warning: Node {node.get('id', 'unknown')} missing 'type', defaulting to 'device'", "warning")
if not node.get("name"):
if node.get("name", None) is None:
node["name"] = node.get("id")
print_status(f"Warning: Node {node.get('id', 'unknown')} missing 'name', defaulting to {node['name']}", "warning")
if not isinstance(node.get("position"), dict):
@@ -66,8 +68,12 @@ def canonicalize_nodes_data(
z = node.pop("z", None)
if z is not None:
node["position"]["position"]["z"] = z
if "sample_id" in node:
sample_id = node.pop("sample_id")
if sample_id:
logger.error(f"{node}的sample_id参数已弃用sample_id: {sample_id}")
for k in list(node.keys()):
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data"]:
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children"]:
v = node.pop(k)
node["config"][k] = v
@@ -629,6 +635,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
{"name": material["name"], "class": className}, resource_type=ResourcePLR
)
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
plr_material.unilabos_uuid = str(uuid.uuid4())
# 处理子物料detail
if material.get("detail") and len(material["detail"]) > 0:
@@ -774,6 +781,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
else:
r = resource_plr
elif resource_class_config["type"] == "unilabos":
raise ValueError(f"No more support for unilabos Resource class {resource_class_config}")
res_instance: RegularContainer = RESOURCE(id=resource_config["name"])
res_instance.ulr_resource = convert_to_ros_msg(
Resource, {k: v for k, v in resource_config.items() if k != "class"}

View File

@@ -26,6 +26,7 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device
d = None
original_device_config = copy.deepcopy(device_config)
device_class_config = device_config["class"]
uid = device_config["uuid"]
if isinstance(device_class_config, str): # 如果是字符串则直接去lab_registry中查找获取class
if len(device_class_config) == 0:
raise DeviceClassInvalid(f"Device [{device_id}] class cannot be an empty string. {device_config}")
@@ -50,7 +51,7 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device
)
try:
d = DEVICE(
device_id=device_id, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.get("config", {})
device_id=device_id, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.get("config", {})
)
except DeviceInitError as ex:
return d

View File

@@ -6,7 +6,7 @@ import threading
import time
import traceback
import uuid
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union
from concurrent.futures import ThreadPoolExecutor
import asyncio
@@ -132,6 +132,7 @@ class ROSLoggerAdapter:
def init_wrapper(
self,
device_id: str,
device_uuid: str,
driver_class: type[T],
device_config: Dict[str, Any],
status_types: Dict[str, Any],
@@ -150,6 +151,7 @@ def init_wrapper(
if children is None:
children = []
kwargs["device_id"] = device_id
kwargs["device_uuid"] = device_uuid
kwargs["driver_class"] = driver_class
kwargs["device_config"] = device_config
kwargs["driver_params"] = driver_params
@@ -266,6 +268,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self,
driver_instance: T,
device_id: str,
device_uuid: str,
status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any],
hardware_interface: Dict[str, Any],
@@ -278,6 +281,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Args:
driver_instance: 设备实例
device_id: 设备标识符
device_uuid: 设备标识符
status_types: 需要发布的状态和传感器信息
action_value_mappings: 设备动作
hardware_interface: 硬件接口配置
@@ -285,7 +289,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
"""
self.driver_instance = driver_instance
self.device_id = device_id
self.uuid = str(uuid.uuid4())
self.uuid = device_uuid
self.publish_high_frequency = False
self.callback_group = ReentrantCallbackGroup()
self.resource_tracker = resource_tracker
@@ -554,6 +558,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
async def update_resource(self, resources: List["ResourcePLR"]):
r = SerialCommand.Request()
tree_set = ResourceTreeSet.from_plr_resources(resources)
for tree in tree_set.trees:
root_node = tree.root_node
if not root_node.res_content.uuid_parent:
logger.warning(f"更新无父节点物料{root_node},自动以当前设备作为根节点")
root_node.res_content.parent_uuid = self.uuid
r.command = json.dumps({"data": {"data": tree_set.dump()}, "action": "update"})
response: SerialCommand_Response = await self._resource_clients["c2s_update_resource_tree"].call_async(r) # type: ignore
try:
@@ -648,15 +657,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
results.append({"success": True, "action": "update"})
elif action == "remove":
# 移除资源
plr_resources: List[ResourcePLR] = [
self.resource_tracker.uuid_to_resources[i] for i in resources_uuid
]
found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource(
[{"uuid": uid} for uid in resources_uuid], try_mode=True
)
found_plr_resources = []
other_plr_resources = []
for res_list in found_resources:
for res in res_list:
if issubclass(res.__class__, ResourcePLR):
found_plr_resources.append(res)
else:
other_plr_resources.append(res)
func = getattr(self.driver_instance, "resource_tree_remove", None)
if callable(func):
func(plr_resources)
for plr_resource in plr_resources:
func(found_plr_resources)
for plr_resource in found_plr_resources:
plr_resource.parent.unassign_child_resource(plr_resource)
self.resource_tracker.remove_resource(plr_resource)
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点")
for res in other_plr_resources:
self.resource_tracker.remove_resource(res)
self.lab_logger().info(f"移除物料 {res} 及其子节点")
results.append({"success": True, "action": "remove"})
except Exception as e:
error_msg = f"Error processing {action} operation: {str(e)}"
@@ -936,7 +957,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 通过资源跟踪器获取本地实例
final_resources = queried_resources if is_sequence else queried_resources[0]
action_kwargs[k] = self.resource_tracker.figure_resource(final_resources, try_mode=False)
final_resources = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) if not is_sequence else [
self.resource_tracker.figure_resource({"name": res.name}, try_mode=False) for res in queried_resources
]
action_kwargs[k] = final_resources
except Exception as e:
self.lab_logger().error(f"{action_name} 物料实例获取失败: {e}\n{traceback.format_exc()}")
@@ -1347,6 +1371,7 @@ class ROS2DeviceNode:
def __init__(
self,
device_id: str,
device_uuid: str,
driver_class: Type[T],
device_config: Dict[str, Any],
driver_params: Dict[str, Any],
@@ -1362,6 +1387,7 @@ class ROS2DeviceNode:
Args:
device_id: 设备标识符
device_uuid: 设备uuid
driver_class: 设备类
device_config: 原始初始化的json
driver_params: driver初始化的参数
@@ -1436,6 +1462,7 @@ class ROS2DeviceNode:
children=children,
driver_instance=self._driver_instance, # type: ignore
device_id=device_id,
device_uuid=device_uuid,
status_types=status_types,
action_value_mappings=action_value_mappings,
hardware_interface=hardware_interface,
@@ -1446,6 +1473,7 @@ class ROS2DeviceNode:
self._ros_node = BaseROS2DeviceNode(
driver_instance=self._driver_instance,
device_id=device_id,
device_uuid=device_uuid,
status_types=status_types,
action_value_mappings=action_value_mappings,
hardware_interface=hardware_interface,

View File

@@ -18,7 +18,7 @@ from unilabos_msgs.srv import (
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
SerialCommand, ResourceGet,
) # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID
@@ -41,6 +41,7 @@ from unilabos.ros.nodes.resource_tracker import (
ResourceTreeSet,
ResourceTreeInstance,
)
from unilabos.utils import logger
from unilabos.utils.exception import DeviceClassInvalid
from unilabos.utils.type_check import serialize_result_info
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
@@ -99,17 +100,6 @@ class HostNode(BaseROS2DeviceNode):
"""
if self._instance is not None:
self._instance.lab_logger().critical("[Host Node] HostNode instance already exists.")
# 初始化Node基类传递空参数覆盖列表
BaseROS2DeviceNode.__init__(
self,
driver_instance=self,
device_id=device_id,
status_types={},
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
hardware_interface={},
print_publish=False,
resource_tracker=self._resource_tracker, # host node并不是通过initialize 包一层传进来的
)
# 设置单例实例
self.__class__._instance = self
@@ -127,6 +117,91 @@ class HostNode(BaseROS2DeviceNode):
bridges = []
self.bridges = bridges
# 创建 host_node 作为一个单独的 ResourceTree
host_node_dict = {
"id": "host_node",
"uuid": str(uuid.uuid4()),
"parent_uuid": "",
"name": "host_node",
"type": "device",
"class": "host_node",
"config": {},
"data": {},
"children": [],
"description": "",
"schema": {},
"model": {},
"icon": "",
}
# 创建 host_node 的 ResourceTree
host_node_instance = ResourceDictInstance.get_resource_instance_from_dict(host_node_dict)
host_node_tree = ResourceTreeInstance(host_node_instance)
resources_config.trees.insert(0, host_node_tree)
try:
for bridge in self.bridges:
if hasattr(bridge, "resource_tree_add") and resources_config:
from unilabos.app.web.client import HTTPClient
client: HTTPClient = bridge
resource_start_time = time.time()
# 传递 ResourceTreeSet 对象,在 client 中转换为字典并获取 UUID 映射
uuid_mapping = client.resource_tree_add(resources_config, "", True)
device_uuid = resources_config.root_nodes[0].res_content.uuid
resource_end_time = time.time()
logger.info(
f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
)
for edge in self.resources_edge_config:
edge["source_uuid"] = uuid_mapping.get(edge["source_uuid"], edge["source_uuid"])
edge["target_uuid"] = uuid_mapping.get(edge["target_uuid"], edge["target_uuid"])
resource_add_res = client.resource_edge_add(self.resources_edge_config)
resource_edge_end_time = time.time()
logger.info(
f"[Host Node-Resource] 物料关系上传 {round(resource_edge_end_time - resource_end_time, 5) * 1000} ms"
)
# resources_config 通过各个设备的 resource_tracker 进行uuid更新利用uuid_mapping
# resources_config 的 root node 是
# # 创建反向映射new_uuid -> old_uuid
# reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()}
# for tree in resources_config.trees:
# node = tree.root_node
# if node.res_content.type == "device":
# if node.res_content.id == "host_node":
# continue
# # slave节点走c2s更新接口拿到add自行update uuid
# device_tracker = self.devices_instances[node.res_content.id].resource_tracker
# old_uuid = reverse_uuid_mapping.get(node.res_content.uuid)
# if old_uuid:
# # 找到旧UUID使用UUID查找
# resource_instance = device_tracker.uuid_to_resources.get(old_uuid)
# else:
# # 未找到旧UUID使用name查找
# resource_instance = device_tracker.figure_resource(
# {"name": node.res_content.name}
# )
# device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
# else:
# try:
# for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
# self.resource_tracker.add_resource(plr_resource)
# except Exception as ex:
# self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!")
except Exception as ex:
logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}")
# 初始化Node基类传递空参数覆盖列表
BaseROS2DeviceNode.__init__(
self,
driver_instance=self,
device_id=device_id,
device_uuid=host_node_dict["uuid"],
status_types={},
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
hardware_interface={},
print_publish=False,
resource_tracker=self._resource_tracker, # host node并不是通过initialize 包一层传进来的
)
# 创建设备、动作客户端和目标存储
self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
@@ -207,81 +282,7 @@ class HostNode(BaseROS2DeviceNode):
].items():
controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config)
# 创建 host_node 作为一个单独的 ResourceTree
host_node_dict = {
"id": "host_node",
"uuid": str(uuid.uuid4()),
"parent_uuid": "",
"name": "host_node",
"type": "device",
"class": "host_node",
"config": {},
"data": {},
"children": [],
"description": "",
"schema": {},
"model": {},
"icon": "",
}
# 创建 host_node 的 ResourceTree
host_node_instance = ResourceDictInstance.get_resource_instance_from_dict(host_node_dict)
host_node_tree = ResourceTreeInstance(host_node_instance)
resources_config.trees.insert(0, host_node_tree)
try:
for bridge in self.bridges:
if hasattr(bridge, "resource_tree_add") and resources_config:
from unilabos.app.web.client import HTTPClient
client: HTTPClient = bridge
resource_start_time = time.time()
# 传递 ResourceTreeSet 对象,在 client 中转换为字典并获取 UUID 映射
uuid_mapping = client.resource_tree_add(resources_config, "", True)
resource_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
)
for edge in self.resources_edge_config:
edge["source_uuid"] = uuid_mapping.get(edge["source_uuid"], edge["source_uuid"])
edge["target_uuid"] = uuid_mapping.get(edge["target_uuid"], edge["target_uuid"])
resource_add_res = client.resource_edge_add(self.resources_edge_config)
resource_edge_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料关系上传 {round(resource_edge_end_time - resource_end_time, 5) * 1000} ms"
)
# resources_config 通过各个设备的 resource_tracker 进行uuid更新利用uuid_mapping
# resources_config 的 root node 是
# 创建反向映射new_uuid -> old_uuid
reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()}
for tree in resources_config.trees:
node = tree.root_node
if node.res_content.type == "device":
for sub_node in node.children:
# 只有二级子设备
if sub_node.res_content.type != "device":
# slave节点走c2s更新接口拿到add自行update uuid
device_tracker = self.devices_instances[node.res_content.id].resource_tracker
# sub_node.res_content.uuid 已经是新UUID需要用旧UUID去查找
old_uuid = reverse_uuid_mapping.get(sub_node.res_content.uuid)
if old_uuid:
# 找到旧UUID使用UUID查找
resource_instance = device_tracker.figure_resource({"uuid": old_uuid})
else:
# 未找到旧UUID使用name查找
resource_instance = device_tracker.figure_resource(
{"name": sub_node.res_content.name}
)
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
else:
try:
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
self.resource_tracker.add_resource(plr_resource)
except Exception as ex:
self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!")
except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc())
# 创建定时器,定期发现设备
self._discovery_timer = self.create_timer(
discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup()
@@ -862,7 +863,7 @@ class HostNode(BaseROS2DeviceNode):
),
}
def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
resource_tree_set = ResourceTreeSet.load(data["data"])
mount_uuid = data["mount_uuid"]
first_add = data["first_add"]
@@ -903,7 +904,7 @@ class HostNode(BaseROS2DeviceNode):
response.response = json.dumps(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
uuid_list: List[str] = data["data"]
with_children: bool = data["with_children"]
from unilabos.app.web.client import http_client
@@ -911,7 +912,7 @@ class HostNode(BaseROS2DeviceNode):
resource_response = http_client.resource_tree_get(uuid_list, with_children)
response.response = json.dumps(resource_response)
def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
"""
子节点通知Host物料树删除
"""
@@ -919,7 +920,7 @@ class HostNode(BaseROS2DeviceNode):
response.response = "OK"
self.lab_logger().info(f"[Host Node-Resource] Resource tree remove completed")
def _resource_tree_action_update_callback(self, data: dict, response: SerialCommand_Response):
async def _resource_tree_action_update_callback(self, data: dict, response: SerialCommand_Response):
"""
子节点通知Host物料树更新
"""
@@ -932,8 +933,17 @@ class HostNode(BaseROS2DeviceNode):
from unilabos.app.web.client import http_client
uuid_to_trees: Dict[str, List[ResourceTreeInstance]] = collections.defaultdict(list)
for tree in resource_tree_set.trees:
uuid_to_trees[tree.root_node.res_content.parent_uuid].append(tree)
for uid, trees in uuid_to_trees.items():
new_tree_set = ResourceTreeSet(trees)
resource_start_time = time.time()
uuid_mapping = http_client.resource_tree_update(resource_tree_set, "", False)
self.lab_logger().info(
f"[Host Node-Resource] 物料 {[root_node.res_content.id for root_node in new_tree_set.root_nodes]} {uid} 挂载 {trees[0].root_node.res_content.parent_uuid} 请求更新上传"
)
uuid_mapping = http_client.resource_tree_add(new_tree_set, uid, False)
success = bool(uuid_mapping)
resource_end_time = time.time()
self.lab_logger().info(
@@ -945,7 +955,7 @@ class HostNode(BaseROS2DeviceNode):
response.response = json.dumps(uuid_mapping)
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
"""
子节点通知Host物料树更新
@@ -958,13 +968,13 @@ class HostNode(BaseROS2DeviceNode):
action = data["action"]
data = data["data"]
if action == "add":
self._resource_tree_action_add_callback(data, response)
await self._resource_tree_action_add_callback(data, response)
elif action == "get":
self._resource_tree_action_get_callback(data, response)
await self._resource_tree_action_get_callback(data, response)
elif action == "update":
self._resource_tree_action_update_callback(data, response)
await self._resource_tree_action_update_callback(data, response)
elif action == "remove":
self._resource_tree_action_remove_callback(data, response)
await self._resource_tree_action_remove_callback(data, response)
else:
self.lab_logger().error(f"[Host Node-Resource] Invalid action: {action}")
response.response = "ERROR"

View File

@@ -6,13 +6,14 @@ from typing import List, Dict, Any, Optional, TYPE_CHECKING
import rclpy
from rosidl_runtime_py import message_to_ordereddict
from unilabos_msgs.msg import Resource
from unilabos_msgs.srv import ResourceUpdate
from unilabos.messages import * # type: ignore # protocol names
from rclpy.action import ActionServer, ActionClient
from rclpy.action.server import ServerGoalHandle
from rclpy.callback_groups import ReentrantCallbackGroup
from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ResourceGet, ResourceUpdate # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.compile import action_protocol_generators
from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list
@@ -20,11 +21,11 @@ from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import (
get_action_type,
convert_to_ros_msg,
convert_from_ros_msg,
convert_from_ros_msg_with_mapping,
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode
from unilabos.utils.type_check import serialize_result_info, get_result_info_str
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils.type_check import get_result_info_str
if TYPE_CHECKING:
from unilabos.devices.workstation.workstation_base import WorkstationBase
@@ -50,6 +51,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
*,
driver_instance: "WorkstationBase",
device_id: str,
device_uuid: str,
status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any],
hardware_interface: Dict[str, Any],
@@ -64,6 +66,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
super().__init__(
driver_instance=driver_instance,
device_id=device_id,
device_uuid=device_uuid,
status_types=status_types,
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
hardware_interface=hardware_interface,
@@ -222,16 +225,28 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
# 向Host查询物料当前状态
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
r = ResourceGet.Request()
self.lab_logger().info(f"{protocol_name} 查询资源状态: Key: {k} Type: {v}")
try:
# 统一处理单个或多个资源
resource_id = (
protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"]
)
r.id = resource_id
r.with_children = True
response = await self._resource_clients["resource_get"].call_async(r)
protocol_kwargs[k] = list_to_nested_dict(
[convert_from_ros_msg(rs) for rs in response.resources]
)
r = SerialCommand_Request()
r.command = json.dumps({"id": resource_id, "with_children": True})
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients[
"resource_get"
].call_async(
r
) # type: ignore
raw_data = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_list(raw_data)
target = tree_set.dump()
protocol_kwargs[k] = target[0][0] if v == "unilabos_msgs/Resource" else target
except Exception as ex:
self.lab_logger().error(f"查询资源失败: {k}, 错误: {ex}\n{traceback.format_exc()}")
raise
self.lab_logger().info(f"🔍 最终的 vessel: {protocol_kwargs.get('vessel', 'NOT_FOUND')}")

View File

@@ -1,3 +1,4 @@
import traceback
import uuid
from pydantic import BaseModel, field_serializer, field_validator
from pydantic import Field
@@ -140,7 +141,7 @@ class ResourceDictInstance(object):
def get_nested_dict(self) -> Dict[str, Any]:
"""获取资源实例的嵌套字典表示"""
res_dict = self.res_content.model_dump(by_alias=True)
res_dict["children"] = {child.res_content.name: child.get_nested_dict() for child in self.children}
res_dict["children"] = {child.res_content.id: child.get_nested_dict() for child in self.children}
res_dict["parent"] = self.res_content.parent_instance_name
res_dict["position"] = self.res_content.position.position.model_dump()
return res_dict
@@ -213,7 +214,7 @@ class ResourceTreeInstance(object):
if node.res_content.uuid:
known_uuids.add(node.res_content.uuid)
else:
print(f"警告: 资源 {node.res_content.id} 没有uuid")
logger.warning(f"警告: 资源 {node.res_content.id} 没有uuid")
# 验证并递归处理子节点
for child in node.children:
@@ -289,8 +290,6 @@ class ResourceTreeSet(object):
elif isinstance(resource_list[0], ResourceTreeInstance):
# 已经是ResourceTree列表
self.trees = cast(List[ResourceTreeInstance], resource_list)
elif isinstance(resource_list[0], list):
pass
else:
raise TypeError(
f"不支持的类型: {type(resource_list[0])}"
@@ -307,10 +306,7 @@ class ResourceTreeSet(object):
replace_info = {
"plate": "plate",
"well": "well",
"tip_spot": "container",
"trash": "container",
"deck": "deck",
"tip_rack": "container",
}
if source in replace_info:
return replace_info[source]
@@ -320,7 +316,12 @@ class ResourceTreeSet(object):
def build_uuid_mapping(res: "PLRResource", uuid_list: list):
"""递归构建uuid映射字典"""
uuid_list.append(getattr(res, "unilabos_uuid", ""))
uid = getattr(res, "unilabos_uuid", "")
if not uid:
uid = str(uuid.uuid4())
res.unilabos_uuid = uid
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}")
uuid_list.append(uid)
for child in res.children:
build_uuid_mapping(child, uuid_list)
@@ -384,7 +385,7 @@ class ResourceTreeSet(object):
import inspect
# 类型映射
TYPE_MAP = {"plate": "plate", "well": "well", "container": "tip_spot", "deck": "deck", "tip_rack": "tip_rack"}
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck"}
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict):
"""一次遍历收集 name_to_uuid 和 all_states"""
@@ -396,13 +397,13 @@ class ResourceTreeSet(object):
def node_to_plr_dict(node: ResourceDictInstance, has_model: bool):
"""转换节点为 PLR 字典格式"""
res = node.res_content
plr_type = TYPE_MAP.get(res.type, "tip_spot")
plr_type = TYPE_MAP.get(res.type, res.type)
if res.type not in TYPE_MAP:
logger.warning(f"未知类型 {res.type},使用默认类型 tip_spot")
logger.warning(f"未知类型 {res.type}")
d = {
"name": res.name,
"type": plr_type,
"type": res.config.get("type", plr_type),
"size_x": res.config.get("size_x", 0),
"size_y": res.config.get("size_y", 0),
"size_z": res.config.get("size_z", 0),
@@ -413,7 +414,7 @@ class ResourceTreeSet(object):
"type": "Coordinate",
},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"category": plr_type,
"category": res.config.get("category", plr_type),
"children": [node_to_plr_dict(child, has_model) for child in node.children],
"parent_name": res.parent_instance_name,
**res.config,
@@ -435,7 +436,7 @@ class ResourceTreeSet(object):
try:
sub_cls = find_subclass(plr_dict["type"], PLRResource)
if sub_cls is None:
raise ValueError(f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类")
raise ValueError(f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}")
spec = inspect.signature(sub_cls)
if "category" not in spec.parameters:
plr_dict.pop("category", None)
@@ -715,16 +716,9 @@ class ResourceTreeSet(object):
Returns:
ResourceTreeSet: 反序列化后的资源树集合
"""
# 将每个字典转换为 ResourceInstanceDict
# FIXME: 需要重新确定parent关系
nested_lists = []
for tree_data in data:
flatten_instances = [
ResourceDictInstance.get_resource_instance_from_dict(node_dict) for node_dict in tree_data
]
nested_lists.append(flatten_instances)
# 使用现有的构造函数创建 ResourceTreeSet
nested_lists.extend(ResourceTreeSet.from_raw_list(tree_data).trees)
return cls(nested_lists)
@@ -777,7 +771,8 @@ class DeviceNodeResourceTracker(object):
else:
return getattr(resource, uuid_attr, None)
def _set_resource_uuid(self, resource, new_uuid: str):
@classmethod
def set_resource_uuid(cls, resource, new_uuid: str):
"""
设置资源的 uuid统一处理 dict 和 instance 两种类型
@@ -830,7 +825,7 @@ class DeviceNodeResourceTracker(object):
resource_name = self._get_resource_attr(res, "name")
if resource_name and resource_name in name_to_uuid_map:
new_uuid = name_to_uuid_map[resource_name]
self._set_resource_uuid(res, new_uuid)
self.set_resource_uuid(res, new_uuid)
self.uuid_to_resources[new_uuid] = res
logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}")
return 1
@@ -842,7 +837,7 @@ class DeviceNodeResourceTracker(object):
"""
递归遍历资源树更新所有节点的uuid
Args:
Args:0
resource: 资源对象可以是dict或实例
uuid_map: uuid映射字典{old_uuid: new_uuid}
@@ -852,17 +847,18 @@ class DeviceNodeResourceTracker(object):
def process(res):
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
replaced = 0
if current_uuid and current_uuid in uuid_map:
new_uuid = uuid_map[current_uuid]
if current_uuid != new_uuid:
self._set_resource_uuid(res, new_uuid)
self.set_resource_uuid(res, new_uuid)
# 更新uuid_to_resources映射
if current_uuid in self.uuid_to_resources:
self.uuid_to_resources.pop(current_uuid)
self.uuid_to_resources[new_uuid] = res
logger.debug(f"更新uuid: {current_uuid} -> {new_uuid}")
return 1
return 0
replaced = 1
return replaced
return self._traverse_and_process(resource, process)
@@ -877,8 +873,9 @@ class DeviceNodeResourceTracker(object):
def process(res):
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
if current_uuid:
old = self.uuid_to_resources.get(current_uuid)
self.uuid_to_resources[current_uuid] = res
logger.debug(f"收集资源UUID映射: {current_uuid} -> {res}")
logger.debug(f"收集资源UUID映射: {current_uuid} -> {res} {'' if old is None else f'(覆盖旧值: {old})'}")
return 0
self._traverse_and_process(resource, process)
@@ -913,9 +910,23 @@ class DeviceNodeResourceTracker(object):
Args:
resource: 资源对象可以是dict或实例
"""
root_uuids = {}
for r in self.resources:
res_uuid = r.get("uuid") if isinstance(r, dict) else getattr(r, "unilabos_uuid", None)
if res_uuid:
root_uuids[res_uuid] = r
if id(r) == id(resource):
return
# 这里只做uuid的根节点比较
if isinstance(resource, dict):
res_uuid = resource.get("uuid")
else:
res_uuid = getattr(resource, "unilabos_uuid", None)
if res_uuid in root_uuids:
old_res = root_uuids[res_uuid]
# self.remove_resource(old_res)
logger.warning(f"资源{resource}已存在,旧资源: {old_res}")
self.resources.append(resource)
# 递归收集uuid映射
self._collect_uuid_mapping(resource)
@@ -1046,13 +1057,19 @@ class DeviceNodeResourceTracker(object):
) -> List[Tuple[Any, Any]]:
res_list = []
# print(resource, target_resource_cls_type, identifier_key, compare_value)
children = []
if not isinstance(resource, dict):
children = getattr(resource, "children", [])
else:
children = resource.get("children")
if children is not None:
children = list(children.values()) if isinstance(children, dict) else children
for child in children:
res_list.extend(
self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource)
)
if issubclass(type(resource), target_resource_cls_type):
if target_resource_cls_type == dict:
if type(resource) == dict:
# 对于字典类型,直接检查 identifier_key
if identifier_key in resource:
if resource[identifier_key] == compare_value:

View File

@@ -336,6 +336,9 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
try:
# 创建实例额外补充一个给protocol node的字段后面考虑取消
data["children"] = self.children
for material_id, child in self.children.items():
if child["type"] != "device":
self.resource_tracker.add_resource(self.children[material_id])
deck_dict = data.get("deck")
if deck_dict:
from pylabrobot.resources import Deck, Resource