diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 0c119759..7e278a9b 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -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 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d19dbb87..66aef8d6 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -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: | diff --git a/docs/boot_examples/organic_synthesis.md b/docs/boot_examples/organic_synthesis.md index 31b83df7..23b970fd 100644 --- a/docs/boot_examples/organic_synthesis.md +++ b/docs/boot_examples/organic_synthesis.md @@ -91,7 +91,7 @@ 使用以下命令启动模拟反应器: ```bash -unilab -g test/experiments/mock_reactor.json --app_bridges "" +unilab -g test/experiments/mock_reactor.json ``` ### 2. 执行抽真空和充气操作 diff --git a/docs/conf.py b/docs/conf.py index a6dc55a9..c6b7d50a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ extensions = [ "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings - "sphinx_rtd_theme" + "sphinx_rtd_theme", ] source_suffix = { diff --git a/test/experiments/comprehensive_protocol/comprehensive_station.json b/test/experiments/comprehensive_protocol/comprehensive_station.json index 1da0d1df..9af64af3 100644 --- a/test/experiments/comprehensive_protocol/comprehensive_station.json +++ b/test/experiments/comprehensive_protocol/comprehensive_station.json @@ -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" }, diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index b2f79c80..745e1289 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -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": {} @@ -57,4 +50,4 @@ "data": {} } ] -} +} \ No newline at end of file diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 2a18d90a..f09aeb91 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -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", diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 110ca040..b65da8e9 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -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" diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index d070e690..b8c8bea3 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -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: diff --git a/unilabos/config/config.py b/unilabos/config/config.py index e0664449..b5bc6191 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -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): diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index b1820d6c..11b011cc 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -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"] diff --git a/unilabos/devices/workstation/bioyond_studio/experiment.py b/unilabos/devices/workstation/bioyond_studio/experiment.py index ae3111b8..92e52b45 100644 --- a/unilabos/devices/workstation/bioyond_studio/experiment.py +++ b/unilabos/devices/workstation/bioyond_studio/experiment.py @@ -1,203 +1,205 @@ -# 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(): """运行实验流程""" - + # 初始化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") - + # 1. 反应器放入 print("1. 添加反应器放入工作流,带参数...") Bioyond.reactor_taken_in( - assign_material_name="BTDA-DD", - cutoff="10000", + assign_material_name="BTDA-DD", + cutoff="10000", temperature="-10" ) - + # 2. 液体投料-烧杯 (第一个) print("2. 添加液体投料-烧杯,带参数...") Bioyond.liquid_feeding_beaker( - volume="34768.7", + volume="34768.7", assign_material_name="ODA", - time="0", - torque_variation="1", - titrationType="1", + time="0", + torque_variation="1", + titrationType="1", temperature=-10 ) - + # 3. 液体投料-烧杯 (第二个) print("3. 添加液体投料-烧杯,带参数...") Bioyond.liquid_feeding_beaker( - volume="34080.9", + volume="34080.9", assign_material_name="MPDA", - time="5", - torque_variation="2", - titrationType="1", + time="5", + torque_variation="2", + titrationType="1", temperature=0 ) - + # 4. 液体投料-小瓶非滴定 print("4. 添加液体投料-小瓶非滴定,带参数...") Bioyond.liquid_feeding_vials_non_titration( - volumeFormula="639.5", - assign_material_name="SIDA", - titration_type="1", - time="0", - torque_variation="1", + volumeFormula="639.5", + assign_material_name="SIDA", + titration_type="1", + time="0", + torque_variation="1", temperature=-10 ) - + # 5. 液体投料溶剂 print("5. 添加液体投料溶剂,带参数...") Bioyond.liquid_feeding_solvents( assign_material_name="NMP", - volume="19000", - titration_type="1", - time="5", - torque_variation="2", + volume="19000", + titration_type="1", + time="5", + torque_variation="2", temperature=-10 ) - + # 6-8. 固体进料小瓶 (三个) print("6. 添加固体进料小瓶,带参数...") Bioyond.solid_feeding_vials( - material_id="3", - time="180", + 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", + 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", + 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", - torque_variation="2", + titration_type="1", + time="360", + torque_variation="2", temperature="25.00" ) - + # 液体投料滴定(第二个) print("10. 添加液体投料滴定,带参数...") # ODPA Bioyond.liquid_feeding_titration( - volume_formula="500", + volume_formula="500", assign_material_name="BTDA-DD", - titration_type="1", - time="360", - torque_variation="2", + titration_type="1", + time="360", + torque_variation="2", temperature="25.00" ) # 液体投料滴定(第三个) print("11. 添加液体投料滴定,带参数...") # ODPA Bioyond.liquid_feeding_titration( - volume_formula="500", + volume_formula="500", assign_material_name="BTDA-DD", - titration_type="1", - time="360", - torque_variation="2", + titration_type="1", + time="360", + torque_variation="2", temperature="25.00" ) - + print("12. 添加液体投料滴定,带参数...") # ODPA Bioyond.liquid_feeding_titration( - volume_formula="500", + volume_formula="500", assign_material_name="BTDA-DD", - titration_type="1", - time="360", - torque_variation="2", + titration_type="1", + time="360", + torque_variation="2", temperature="25.00" ) - + print("13. 添加液体投料滴定,带参数...") # ODPA Bioyond.liquid_feeding_titration( - volume_formula="500", + volume_formula="500", assign_material_name="BTDA-DD", - titration_type="1", - time="360", - torque_variation="2", + titration_type="1", + time="360", + torque_variation="2", temperature="25.00" ) - + print("14. 添加液体投料滴定,带参数...") # ODPA Bioyond.liquid_feeding_titration( - volume_formula="500", + volume_formula="500", assign_material_name="BTDA-DD", - titration_type="1", - time="360", - torque_variation="2", + titration_type="1", + time="360", + torque_variation="2", temperature="25.00" ) - - print("15. 添加液体投料溶剂,带参数...") Bioyond.liquid_feeding_solvents( assign_material_name="PGME", - volume="16894.6", - titration_type="1", - time="360", - torque_variation="2", + volume="16894.6", + titration_type="1", + time="360", + torque_variation="2", temperature=25.00 ) - + # 16. 反应器取出 print("16. 添加反应器取出工作流...") Bioyond.reactor_taken_out() - + # 显示当前工作流序列 sequence = Bioyond.get_workflow_sequence() print("\n当前工作流执行顺序:") print(sequence) - + # 执行process_and_execute_workflow,合并工作流并创建任务 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" ) - + # 显示执行结果 print("\n5. 执行结果:") if isinstance(result, str): @@ -220,16 +222,16 @@ def run_experiment(): print(f"- 任务结果: {result.get('task')}") else: print(f"任务创建失败: {result.get('error')}") - + # 可选:启动调度器 # Bioyond.scheduler_start() - + return Bioyond def prepare_materials(bioyond): """准备实验材料(可选)""" - + # 样品板材料数据定义 material_data_yp_1 = { "typeId": "3a142339-80de-8f25-6093-1b1b1b6c322e", @@ -288,7 +290,7 @@ def prepare_materials(bioyond): ], "Parameters": "{}" } - + material_data_yp_2 = { "typeId": "3a142339-80de-8f25-6093-1b1b1b6c322e", "name": "样品板-2", @@ -338,7 +340,7 @@ def prepare_materials(bioyond): ], "Parameters": "{}" } - + # 烧杯材料数据定义 beaker_materials = [ { @@ -377,12 +379,12 @@ def prepare_materials(bioyond): "parameters": "{\"DeviceMaterialType\":\"NMP\"}" } ] - + # 如果需要,可以在这里调用add_material方法添加材料 # 例如: # result = bioyond.add_material(json.dumps(material_data_yp_1)) # print(f"添加材料结果: {result}") - + return { "sample_plates": [material_data_yp_1, material_data_yp_2], "beakers": beaker_materials @@ -392,7 +394,7 @@ def prepare_materials(bioyond): if __name__ == "__main__": # 运行主实验流程 bioyond_client = run_experiment() - + # 可选:准备材料数据 # materials = prepare_materials(bioyond_client) # print(f"\n准备的材料数据: {materials}") diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station.py b/unilabos/devices/workstation/bioyond_studio/reaction_station.py index e35c657f..5bb8709c 100644 --- a/unilabos/devices/workstation/bioyond_studio/reaction_station.py +++ b/unilabos/devices/workstation/bioyond_studio/reaction_station.py @@ -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) @@ -204,4 +317,323 @@ class BioyondReactionStation(BioyondWorkstation): self.pending_task_params.append(params) print(f"成功添加液体进料烧杯参数: volume={volume}μL, material={assign_material_name}->ID:{material_id}") print(f"当前队列长度: {len(self.pending_task_params)}") - return json.dumps({"suc": True}) \ No newline at end of file + 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 \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index f415a363..910fdb3a 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -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: diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py index 1988249f..97db1505 100644 --- a/unilabos/devices/workstation/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -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): """递归构建资源映射""" diff --git a/unilabos/registry/resources/organic/container.yaml b/unilabos/registry/resources/organic/container.yaml index 6a52caf3..7da736c0 100644 --- a/unilabos/registry/resources/organic/container.yaml +++ b/unilabos/registry/resources/organic/container.yaml @@ -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 diff --git a/unilabos/resources/container.py b/unilabos/resources/container.py index 644bfe88..23b044c7 100644 --- a/unilabos/resources/container.py +++ b/unilabos/resources/container.py @@ -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}" \ No newline at end of file + 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}" \ No newline at end of file diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index b9b63f34..98f6c74e 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -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"} diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index bbc86e04..a92a9f50 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -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 diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index b62ad2d9..5900acac 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -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, diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 3d5bd165..d265e13a 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -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,20 +933,29 @@ class HostNode(BaseROS2DeviceNode): from unilabos.app.web.client import http_client - resource_start_time = time.time() - uuid_mapping = http_client.resource_tree_update(resource_tree_set, "", False) - success = bool(uuid_mapping) - resource_end_time = time.time() - self.lab_logger().info( - f"[Host Node-Resource] 物料更新上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms" - ) - if uuid_mapping: - self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点") - # 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式 - response.response = json.dumps(uuid_mapping) - self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") + 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) - def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response): + for uid, trees in uuid_to_trees.items(): + new_tree_set = ResourceTreeSet(trees) + resource_start_time = time.time() + 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( + f"[Host Node-Resource] 物料更新上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms" + ) + if uuid_mapping: + self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点") + # 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式 + response.response = json.dumps(uuid_mapping) + self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") + + 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" diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index 07e35ee6..745ec196 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -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"]: - r = ResourceGet.Request() - 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] - ) + 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 = 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')}") diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 30567aec..22919c5c 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -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 = getattr(resource, "children", []) + 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: diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index f72edf29..9481ce31 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -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