Compare commits

..

33 Commits

Author SHA1 Message Date
hanhua
882c33bd22 update action
update action
2026-01-15 10:05:03 +08:00
ZiWei
ad21644db0 fix: WareHouse 的不可哈希类型错误,优化父节点去重逻辑 2026-01-14 20:15:05 +08:00
Xuwznln
9dfd58e9af fix parent_uuid fetch when bind_parent_id == node_name 2026-01-14 14:17:29 +08:00
Xuwznln
31c9f9a172 物料更新也是用父节点进行报送 2026-01-13 20:21:37 +08:00
Xuwznln
02cd8de4c5 Add None conversion for tube rack etc. 2026-01-13 17:49:11 +08:00
Xuwznln
a66603ec1c Add set_liquid example. 2026-01-12 22:24:01 +08:00
Xuwznln
ec015e16cd Add create_resource and test_resource example. 2026-01-12 21:17:28 +08:00
Xuwznln
965bf36e8d Add restart.
Temp allow action message.
2026-01-11 21:25:59 +08:00
Xuwznln
aacf3497e0 Add no_update_feedback option. 2026-01-09 17:18:39 +08:00
Xuwznln
657f952e7a Create session_id by edge. 2026-01-09 12:01:57 +08:00
Xuwznln
0165590290 bump version to 0.10.15 2026-01-08 15:37:49 +08:00
Xuwznln
daea1ab54d temp cancel update req 2026-01-08 15:26:31 +08:00
Xuwznln
93cb307396 Fix update with different spot and same parent 2026-01-08 03:46:00 +08:00
Xuwznln
1c312772ae Force update resource when adding new resource / transfer to another resource 2026-01-08 03:07:12 +08:00
Xuwznln
bad1db5094 location not passed to ItemizedCarrier when assign child resource 2026-01-08 03:07:11 +08:00
Xuwznln
f26eb69eca Fix size not pass through. 2026-01-08 03:07:11 +08:00
Xuwznln
12c0770c92 Fix build on macos-intel 2026-01-07 21:11:10 +08:00
Xuwznln
3d2d428a96 Update README.md
Modify resource_tracker file module path.

(cherry picked from commit 8066c200b9)
2026-01-07 20:54:43 +08:00
Xuwznln
78bf57f590 Bump version to 0.10.4 2026-01-07 20:41:23 +08:00
Xuwznln
e227cddab3 Update LICENSE 2026-01-07 20:40:02 +08:00
Xuwznln
f2b993643f Fix drag materials. 2026-01-07 19:40:29 +08:00
Xuwznln
2e14bf197c Fix and tested new create_resource. 2026-01-07 19:26:42 +08:00
Xuwznln
66c18c080a Update create_resource to resource tree mode. 2026-01-07 02:03:43 +08:00
Xuwznln
a1c34f138e Close #208. Fix mock devices.
(cherry picked from commit 28f93737ac)
2025-12-28 23:24:44 +08:00
Xianwei Qi
75bb5ec553 test_transfer_liquid_2 2025-12-26 16:42:50 +08:00
Xianwei Qi
bb95c89829 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-12-26 16:25:19 +08:00
Xianwei Qi
394c140830 test_transfer_liquid 2025-12-26 16:24:55 +08:00
Xuwznln
e6d8d41183 bump version to 0.10.3 2025-12-26 03:26:50 +08:00
Xuwznln
847a300af3 update registry 2025-12-26 03:26:46 +08:00
Xuwznln
a201d7c307 update registry 2025-12-26 03:26:45 +08:00
Xuwznln
3433766bc5 do not modify globally 2025-12-26 03:26:44 +08:00
Xuwznln
7e9e93b29c Prcix9320 (#207)
* 0.10.7 Update (#101)

* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>

* fix startup env check.
add auto install during one-key installation

* Try fix one-key build on linux

* Complete all one key installation

* fix: rename schema field to resource_schema with serialization and validation aliases (#104)

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* Fix one-key installation build

Install conda-pack before pack command

Add conda-pack to base when building one-key installer

Fix param error when using mamba run

Try fix one-key build on linux

* Fix conda pack on windows

* add plr_to_bioyond, and refactor bioyond stations

* modify default config

* Fix one-key installation build for windows

* Fix workstation startup
Update registry

* 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>

* Close #107
Update doc url.

* Fix/update resource (#112)

* cancel upload_registry

* Refactor Bioyond workstation and experiment workflow -fix (#111)

* refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑

改进材料缓存加载逻辑以支持多种材料类型和详细材料处理
更新工作流参数验证中的字段名从key/value改为Key/DisplayValue
移除未使用的merge_workflow_with_parameters方法
添加get_station_info方法获取工作站基础信息
清理实验文件中的注释代码和更新导入路径

* fix: 修复资源移除时的父资源检查问题

在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常
同时更新Bottle和BottleCarrier类以支持**kwargs参数
修正测试文件中Liquid_feeding_beaker的大小写拼写错误

* correct return message

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* fix resource_get in action

* fix(reaction_station): 清空工作流序列和参数避免重复执行 (#113)

在创建任务后清空工作流序列和参数,防止下次执行时累积重复

* Update create_resource device_id

* Update ResourceTracker

add more enumeration in POSE

fix converter in resource_tracker

* Update graphio together with workstation design.

fix(reaction_station): 为步骤参数添加Value字段传个BY后端

fix(bioyond/warehouses): 修正仓库尺寸和物品排列参数

调整仓库的x轴和z轴物品数量以及物品尺寸参数,使其符合4x1x4的规格要求

fix warehouse serialize/deserialize

fix bioyond converter

fix itemized_carrier.unassign_child_resource

allow not-loaded MSG in registry

add layout serializer & converter

warehouseuse A1-D4; add warehouse layout

fix(graphio): 修正bioyond到plr资源转换中的坐标计算错误

Fix resource assignment and type mapping issues

Corrects resource assignment in ItemizedCarrier by using the correct spot key from _ordering. Updates graphio to use 'typeName' instead of 'name' for type mapping in resource_bioyond_to_plr. Renames DummyWorkstation to BioyondWorkstation in workstation_http_service for clarity.

* Update workstation & bioyond example

Refine descriptions in Bioyond reaction station YAML

Updated and clarified field and operation descriptions in the reaction_station_bioyond.yaml file for improved accuracy and consistency. Changes include more precise terminology, clearer parameter explanations, and standardized formatting for operation schemas.

refactor(workstation): 更新反应站参数描述并添加分液站配置文件

修正反应站方法参数描述,使其更准确清晰
添加bioyond_dispensing_station.yaml配置文件

add create_workflow script and test

add invisible_slots to carriers

fix(warehouses): 修正bioyond_warehouse_1x4x4仓库的尺寸参数

调整仓库的num_items_x和num_items_z值以匹配实际布局,并更新物品尺寸参数

save resource get data. allow empty value for layout and cross_section_type

More decks&plates support for bioyond (#115)

refactor(registry): 重构反应站设备配置,简化并更新操作命令

移除旧的自动操作命令,新增针对具体化学操作的命令配置
更新模块路径和配置结构,优化参数定义和描述

fix(dispensing_station): 修正物料信息查询方法调用

将直接调用material_id_query改为通过hardware_interface调用,以符合接口设计规范

* PRCXI Update

修改prcxi连线

prcxi样例图

Create example_prcxi.json

* Update resource extra & uuid.

use ordering to convert identifier to idx

convert identifier to site idx

correct extra key

update extra before transfer

fix multiple instance error

add resource_tree_transfer func

fox itemrized carrier assign child resource

support internal device material transfer

remove extra key

use same callback group

support material extra

support material extra
support update_resource_site in extra

* Update workstation.

modify workstation_architecture docs

bioyond_HR (#133)

* feat: Enhance Bioyond synchronization and resource management

- Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type.
- Improved error handling and logging during synchronization processes.
- Added functionality to save Bioyond material IDs in UniLab resources for future updates.
- Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond.
- Updated warehouse configurations to support new storage types and improved layout for better resource management.
- Introduced new resource types such as reactors and tip boxes, with detailed specifications.
- Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08).
- Improved resource tracking by merging extra attributes instead of overwriting them.
- Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes.

* feat: 添加TipBox和Reactor的配置到bottles.yaml

* fix: 修复液体投料方法中的volume参数处理逻辑

修复solid_feeding_vials方法中的volume参数处理逻辑,优化solvents参数的使用条件

更新液体投料方法,支持通过溶剂信息自动计算体积,添加solvents参数并更新文档描述

Add batch creation methods for vial and solution tasks

添加批量创建90%10%小瓶投料任务和二胺溶液配置任务的功能,更新相关参数和默认值

* 封膜仪、撕膜仪、耗材站接口

* 添加Raman和xrd相关代码

* Resource update & asyncio fix

correct bioyond config

prcxi example

fix append_resource

fix regularcontainer

fix cancel error

fix resource_get param

fix json dumps

support name change during materials change

enable slave mode

change uuid logger to trace level

correct remove_resource stats

disable slave connect websocket

adjust with_children param

modify devices to use correct executor (sleep, create_task)

support sleep and create_task in node

fix run async execution error

* bump version to 0.10.9

update registry

* PRCXI Reset Error Correction (#166)

* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Rewrite setup logic to clear error code

* 初始化 step_mode 属性

* 1114物料手册定义教程byxinyu (#165)

* 宜宾奔耀工站deck前端by_Xinyu

* 构建物料教程byxinyu

* 1114物料手册定义教程

* 3d sim (#97)

* 修改lh的json启动

* 修改lh的json启动

* 修改backend,做成sim的通用backend

* 修改yaml的地址,3D模型适配网页生产环境

* 添加laiyu硬件连接

* 修改移液枪的状态判断方法,

修改移液枪的状态判断方法,
添加三轴的表定点与零点之间的转换
添加三轴真实移动的backend

* 修改laiyu移液站

简化移动方法,
取消软件限制位置,
修改当值使用Z轴时也需要重新复位Z轴的问题

* 更新lh以及laiyu workshop

1,现在可以直接通过修改backend,适配其他的移液站,主类依旧使用LiquidHandler,不用重新编写

2,修改枪头判断标准,使用枪头自身判断而不是类的判断,

3,将归零参数用毫米计算,方便手动调整,

4,修改归零方式,上电使用机械归零,确定机械零点,手动归零设置工作区域零点方便计算,二者互不干涉

* 修改枪头动作

* 修改虚拟仿真方法

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: Junhan Chang <changjh@dp.tech>

* 标准化opcua设备接入unilab (#78)

* 初始提交,只保留工作区当前状态

* remove redundant arm_slider meshes

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>

* add new laiyu liquid driver, yaml and json files (#164)

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

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

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

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

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

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

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

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

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

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

* Refactor Bioyond resource synchronization and update bottle carrier definitions

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

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

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

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

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

* support name change during materials change

* fix json dumps

* correct tip

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

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

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

* 修复 ItemizedCarrier 中的可见性逻辑

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

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

* Fix bioyond bottle_carriers ordering

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

* disable slave connect websocket

* correct remove_resource stats

* change uuid logger to trace level

* enable slave mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
Co-authored-by: Junhan Chang <changjh@dp.tech>

* nmr

* Update devices

* bump version to 0.10.10

* Update repo files.

* Add get_resource_with_dir & get_resource method

* fix camera & workstation & warehouse & reaction station driver

* update docs, test examples
fix liquid_handler init bug

* bump version to 0.10.11

* Add startup_json_path, disable_browser, port config

* Update oss config

* feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能

添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互
实现compute_experiment_design方法用于实验设计计算
新增brief_step_parameters等订单相关接口方法
优化物料转移逻辑,增加异步任务处理
扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能

* feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数

* Support unilabos_samples key

* add session_id and normal_exit

* Add result schema and add TypedDict conversion.

* Fix port error

* Add backend api and update doc

* Add get_regular_container func

* Add get_regular_container func

* Transfer_liquid (#176)

* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Typo in base_device_node.py

* Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity.

* Auto dump logs, fix workstation input schema

* Fix startup with remote resource error

Resource dict fully change to "pose" key

Update oss link

Reduce pylabrobot conversion warning & force enable log dump.

更新 logo 图片

* signal when host node is ready

* fix ros2 future

print all logs to file
fix resource dict dump error

* update version to 0.10.12

* 修改sample_uuid的返回值

* 修改pose标签设定机制

* 添加 aspiate函数返回值

* 返回dispense后的sample_uuid

* 添加self.pending_liquids_dict的重置方法

* 修改prcxi的json文件,解决trach错误问题

* 修改prcxijson,防止PlateT4的硬件错误

* 对laiyu移液站进行部分修改,取消多次初始化的问题

* 修改根据新的物料格式,修改可视化

* 添加切换枪头方法,添加mock振荡与加热方法

* 夹爪添加

* 删除多余的laiyu部分

* 云端可启动夹爪

* Delete __init__.py

* Enhance PRCXI9300 classes with new Container and TipRack implementations, improving state management and initialization logic. Update JSON configuration to reflect type changes for containers and plates.

* 修改上传数据

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
Co-authored-by: hh.(SII) <103566763+Mile-Away@users.noreply.github.com>
Co-authored-by: Xianwei Qi <qxw@stu.pku.edu.cn>
Co-authored-by: WenzheG <wenzheguo32@gmail.com>
Co-authored-by: Harry Liu <113173203+ALITTLELZ@users.noreply.github.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
Co-authored-by: tt <166512503+tt11142023@users.noreply.github.com>
Co-authored-by: xyc <49015816+xiaoyu10031@users.noreply.github.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: zhangshixiang <554662886@qq.com>
Co-authored-by: ALITTLELZ <l_LZlz@163.com>
2025-12-26 02:28:56 +08:00
Xuwznln
9e1e6da505 Add topic config 2025-12-26 02:26:17 +08:00
14 changed files with 408 additions and 1039 deletions

View File

@@ -1,15 +0,0 @@
# Liquid handling 集成测试
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend运行前请确保
1. 已安装包含 `pylabrobot``rclpy` 的运行环境;
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
```bash
export UNILAB_SIM_TEST=1
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
```
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend适合作为 CI 的快速测试。***

View File

@@ -1,547 +0,0 @@
import asyncio
from dataclasses import dataclass
from typing import Any, Iterable, List, Optional, Sequence, Tuple
import pytest
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
@dataclass(frozen=True)
class DummyContainer:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyContainer({self.name})"
@dataclass(frozen=True)
class DummyTipSpot:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyTipSpot({self.name})"
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
for i in range(n):
yield [DummyTipSpot(f"tip_{i}")]
class FakeLiquidHandler(LiquidHandlerAbstract):
"""不初始化真实 backend/deck仅用来记录 transfer_liquid 内部调用序列。"""
def __init__(self, channel_num: int = 8):
# 不调用 super().__init__避免真实硬件/后端依赖
self.channel_num = channel_num
self.support_touch_tip = True
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
async def aspirate(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"aspirate",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def dispense(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"dispense",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def discard_tips(self, use_channels=None, *args, **kwargs):
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
async def custom_delay(self, seconds=0, msg=None):
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
async def touch_tip(self, targets):
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
self.calls.append(("touch_tip", {"targets": targets}))
def run(coro):
return asyncio.run(coro)
def test_one_to_one_single_channel_basic_calls():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(3)]
targets = [DummyContainer(f"T{i}") for i in range(3)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 2, 3],
dis_vols=[4, 5, 6],
mix_times=None, # 应该仍能执行(不 mix
)
)
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
assert [c[0] for c in lh.calls].count("aspirate") == 3
assert [c[0] for c in lh.calls].count("dispense") == 3
assert [c[0] for c in lh.calls].count("discard_tips") == 3
# 每次 aspirate/dispense 都是单孔列表
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [sources[0]]
assert aspirates[0]["vols"] == [1.0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[2]["resources"] == [targets[2]]
assert dispenses[2]["vols"] == [6.0]
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(16))
source = DummyContainer("S0")
target = DummyContainer("T0")
run(
lh.transfer_liquid(
sources=[source],
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5],
dis_vols=[5],
mix_stage="before",
mix_times=1,
mix_vol=3,
)
)
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
assert len(aspirate_calls) >= 2
mix_idx, mix_payload = aspirate_calls[0]
assert mix_payload["resources"] == [target]
assert mix_payload["vols"] == [3]
transfer_idx, transfer_payload = aspirate_calls[1]
assert transfer_payload["resources"] == [source]
assert mix_idx < transfer_idx
def test_one_to_one_eight_channel_groups_by_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = list(range(1, 17))
dis_vols = list(range(101, 117))
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0, # 触发逻辑但不 mix
)
)
# 16 个任务 -> 2 组,每组 8 通道一起做
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == 2
assert len(dispenses) == 2
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
assert dispenses[1]["resources"] == targets[8:16]
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(9)]
targets = [DummyContainer(f"T{i}") for i in range(9)]
with pytest.raises(ValueError, match="multiple of 8"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 9,
dis_vols=[1] * 9,
mix_times=0,
)
)
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(512))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = [i + 1 for i in range(16)]
dis_vols = [200 + i for i in range(16)]
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
offsets = [f"offset_{i}" for i in range(16)]
liquid_heights = [i * 0.5 for i in range(16)]
blow_out_air_volume = [i + 0.05 for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
liquid_height=liquid_heights,
blow_out_air_volume=blow_out_air_volume,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
for batch_idx in range(2):
start = batch_idx * 8
end = start + 8
asp_call = aspirates[batch_idx]
dis_call = dispenses[batch_idx]
assert asp_call["resources"] == sources[start:end]
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
assert asp_call["offsets"] == offsets[start:end]
assert asp_call["liquid_height"] == liquid_heights[start:end]
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
assert dis_call["offsets"] == offsets[start:end]
assert dis_call["liquid_height"] == liquid_heights[start:end]
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(1024))
sources = [DummyContainer(f"S{i}") for i in range(32)]
targets = [DummyContainer(f"T{i}") for i in range(32)]
asp_vols = [i + 1 for i in range(32)]
dis_vols = [300 + i for i in range(32)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0,
)
)
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(pick_calls) == 4
assert len(aspirates) == len(dispenses) == 4
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[-1]["resources"] == sources[24:32]
assert dispenses[0]["resources"] == targets[0:8]
assert dispenses[-1]["resources"] == targets[24:32]
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(3)]
dis_vols = [10, 20, 30] # sum=60
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 1
assert aspirates[0]["resources"] == [source]
assert aspirates[0]["vols"] == [60.0]
assert aspirates[0]["use_channels"] == [0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
def test_one_to_many_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(128))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(8)]
dis_vols = [i + 1 for i in range(8)]
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [source] * 8
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[0]["resources"] == targets
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
assert all(d["resources"] == [target] for d in dispenses)
def test_many_to_one_single_channel_before_stage_mixes_target_once():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer("S0"), DummyContainer("S1")]
target = DummyContainer("T")
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5, 6],
dis_vols=1,
mix_stage="before",
mix_times=2,
mix_vol=4,
)
)
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
assert len(aspirate_calls) >= 1
mix_idx, mix_payload = aspirate_calls[0]
assert mix_payload["resources"] == [target]
assert mix_payload["vols"] == [4]
# 第一個 mix 之後會真正開始吸 source
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
dis_vols = [1, 2, 3]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=dis_vols, # 比例模式
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
def test_many_to_one_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(8)]
target = DummyContainer("T")
asp_vols = [10 + i for i in range(8)]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert aspirates[0]["resources"] == sources
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
assert dispenses[0]["resources"] == [target] * 8
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer("S0"), DummyContainer("S1")]
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
with pytest.raises(ValueError, match="Unsupported transfer mode"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1, 1],
mix_times=0,
)
)
def test_mix_single_target_produces_matching_cycles():
lh = FakeLiquidHandler(channel_num=1)
target = DummyContainer("T_mix")
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
assert all(call["resources"] == [target] for call in aspirates)
assert all(call["vols"] == [5] for call in aspirates)
assert all(call["resources"] == [target] for call in dispenses)
assert all(call["vols"] == [5] for call in dispenses)
def test_mix_multiple_targets_supports_per_target_offsets():
lh = FakeLiquidHandler(channel_num=1)
targets = [DummyContainer("T0"), DummyContainer("T1")]
offsets = ["left", "right"]
heights = [0.1, 0.2]
rates = [0.5, 1.0]
run(
lh.mix(
targets=targets,
mix_time=1,
mix_vol=3,
offsets=offsets,
height_to_bottom=heights,
mix_rate=rates,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 2
assert aspirates[0]["resources"] == [targets[0]]
assert aspirates[0]["offsets"] == [offsets[0]]
assert aspirates[0]["liquid_height"] == [heights[0]]
assert aspirates[0]["flow_rates"] == [rates[0]]
assert aspirates[1]["resources"] == [targets[1]]
assert aspirates[1]["offsets"] == [offsets[1]]
assert aspirates[1]["liquid_height"] == [heights[1]]
assert aspirates[1]["flow_rates"] == [rates[1]]

View File

@@ -848,7 +848,7 @@ class MessageProcessor:
device_action_groups[key_add].append(item["uuid"]) device_action_groups[key_add].append(item["uuid"])
logger.info( logger.info(
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}" f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
) )
else: else:
# 正常update # 正常update
@@ -863,11 +863,11 @@ class MessageProcessor:
device_action_groups[key] = [] device_action_groups[key] = []
device_action_groups[key].append(item["uuid"]) device_action_groups[key].append(item["uuid"])
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}") logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
# 为每个(device_id, action)创建独立的更新线程 # 为每个(device_id, action)创建独立的更新线程
for (device_id, actual_action), items in device_action_groups.items(): for (device_id, actual_action), items in device_action_groups.items():
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}") logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
def _notify_resource_tree(dev_id, act, item_list): def _notify_resource_tree(dev_id, act, item_list):
try: try:

View File

@@ -207,14 +207,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
res_samples = [] res_samples = []
res_volumes = [] res_volumes = []
# 处理 use_channels 为 None 的情况(通常用于单通道操作) for resource, volume, channel in zip(resources, vols, use_channels):
if use_channels is None:
# 对于单通道操作,推断通道为 [0]
channels_to_use = [0] * len(resources)
else:
channels_to_use = use_channels
for resource, volume, channel in zip(resources, vols, channels_to_use):
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)}) res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
res_volumes.append(volume) res_volumes.append(volume)
self.pending_liquids_dict[channel] = { self.pending_liquids_dict[channel] = {
@@ -927,7 +920,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if delays is not None and len(delays) > 1: if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
@@ -991,7 +983,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if delays is not None and len(delays) > 1: if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
@@ -1174,7 +1165,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
await self.aspirate( await self.aspirate(
@@ -1209,7 +1199,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if delays is not None and len(delays) > 1: if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
@@ -1246,7 +1235,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
await self.aspirate( await self.aspirate(
@@ -1283,7 +1271,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if delays is not None and len(delays) > 1: if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
@@ -1340,7 +1327,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None, offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
# 从源容器吸液(总体积) # 从源容器吸液(总体积)
@@ -1380,7 +1366,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[idx:idx+1] if offsets else None, offsets=offsets[idx:idx+1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if touch_tip: if touch_tip:
await self.touch_tip([target]) await self.touch_tip([target])
@@ -1416,7 +1401,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[i:i + 8] if offsets else None, offsets=offsets[i:i + 8] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
# 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同 # 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同
@@ -1462,7 +1446,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if touch_tip: if touch_tip:
@@ -1514,19 +1497,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
f"(matching `asp_vols`). Got length {len(dis_vols)}." f"(matching `asp_vols`). Got length {len(dis_vols)}."
) )
need_mix_after = mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0
defer_final_discard = need_mix_after or touch_tip
if len(use_channels) == 1: if len(use_channels) == 1:
# 单通道模式:多次吸液,一次分液 # 单通道模式:多次吸液,一次分液
# 先混合前(如果需要)
# 如果需要 before mix先 pick up tip 并执行 mix
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.mix( await self.mix(
targets=[target], targets=[target],
mix_time=mix_times, mix_time=mix_times,
@@ -1534,11 +1508,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None, offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
await self.discard_tips(use_channels=use_channels)
# 从每个源容器吸液并分液到目标容器 # 从每个源容器吸液并分液到目标容器
for idx, source in enumerate(sources): for idx, source in enumerate(sources):
tip = [] tip = []
@@ -1556,10 +1527,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
spread=spread, spread=spread,
) )
if delays is not None: if delays is not None:
await self.custom_delay(seconds=delays[0]) await self.custom_delay(seconds=delays[0])
# 分液到目标容器 # 分液到目标容器
if use_proportional_mixing: if use_proportional_mixing:
# 按不同比例混合:使用对应的 dis_vols # 按不同比例混合:使用对应的 dis_vols
@@ -1575,7 +1546,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None
dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
await self.dispense( await self.dispense(
resources=[target], resources=[target],
vols=[dis_vol], vols=[dis_vol],
@@ -1586,15 +1557,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None, liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
spread=spread, spread=spread,
) )
if delays is not None and len(delays) > 1: if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
if not (defer_final_discard and idx == len(sources) - 1): await self.discard_tips(use_channels=use_channels)
await self.discard_tips(use_channels=use_channels)
# 最后在目标容器中混合(如果需要) # 最后在目标容器中混合(如果需要)
if need_mix_after: if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix( await self.mix(
targets=[target], targets=[target],
mix_time=mix_times, mix_time=mix_times,
@@ -1602,27 +1572,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None, offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if touch_tip: if touch_tip:
await self.touch_tip([target]) await self.touch_tip([target])
if defer_final_discard:
await self.discard_tips(use_channels=use_channels)
elif len(use_channels) == 8: elif len(use_channels) == 8:
# 8通道模式需要确保源数量是8的倍数 # 8通道模式需要确保源数量是8的倍数
if len(sources) % 8 != 0: if len(sources) % 8 != 0:
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.") raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
# 如果需要 before mix先 pick up tips 并执行 mix # 每次处理8个源
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.mix( await self.mix(
targets=[target], targets=[target],
mix_time=mix_times, mix_time=mix_times,
@@ -1630,11 +1591,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None, offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
await self.discard_tips([0,1,2,3,4,5,6,7])
for i in range(0, len(sources), 8): for i in range(0, len(sources), 8):
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
@@ -1692,12 +1650,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if delays is not None and len(delays) > 1: if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
if not (defer_final_discard and i + 8 >= len(sources)): await self.discard_tips([0,1,2,3,4,5,6,7])
await self.discard_tips([0,1,2,3,4,5,6,7])
# 最后在目标容器中混合(如果需要) # 最后在目标容器中混合(如果需要)
if need_mix_after: if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix( await self.mix(
targets=[target], targets=[target],
mix_time=mix_times, mix_time=mix_times,
@@ -1705,15 +1662,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None, offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None, mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
) )
if touch_tip: if touch_tip:
await self.touch_tip([target]) await self.touch_tip([target])
if defer_final_discard:
await self.discard_tips([0,1,2,3,4,5,6,7])
# except Exception as e: # except Exception as e:
# traceback.print_exc() # traceback.print_exc()
# raise RuntimeError(f"Liquid addition failed: {e}") from e # raise RuntimeError(f"Liquid addition failed: {e}") from e
@@ -1733,12 +1686,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
print(f"Waiting time: {msg}") print(f"Waiting time: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}") print(f"Current time: {time.strftime('%H:%M:%S')}")
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}") print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
# Use ROS node sleep if available, otherwise use asyncio.sleep await self._ros_node.sleep(seconds)
if hasattr(self, '_ros_node') and self._ros_node is not None:
await self._ros_node.sleep(seconds)
else:
import asyncio
await asyncio.sleep(seconds)
if msg: if msg:
print(f"Done: {msg}") print(f"Done: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}") print(f"Current time: {time.strftime('%H:%M:%S')}")
@@ -1777,59 +1725,27 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
height_to_bottom: Optional[float] = None, height_to_bottom: Optional[float] = None,
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
use_channels: Optional[List[int]] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
): ):
if mix_time is None or mix_time <= 0: # No mixing required if mix_time is None: # No mixing required
return return
"""Mix the liquid in the target wells.""" """Mix the liquid in the target wells."""
if mix_vol is None:
raise ValueError("`mix_vol` must be provided when `mix_time` is set.")
targets_list: List[Container] = list(targets)
if len(targets_list) == 0:
return
def _expand(value, count: int):
if value is None:
return [None] * count
if isinstance(value, (list, tuple)):
if len(value) != count:
raise ValueError("Length of per-target parameters must match targets.")
return list(value)
return [value] * count
offsets_list = _expand(offsets, len(targets_list))
heights_list = _expand(height_to_bottom, len(targets_list))
rates_list = _expand(mix_rate, len(targets_list))
for _ in range(mix_time): for _ in range(mix_time):
for idx, target in enumerate(targets_list): await self.aspirate(
offset_arg = ( resources=[targets],
[offsets_list[idx]] if offsets_list[idx] is not None else None vols=[mix_vol],
) flow_rates=[mix_rate] if mix_rate else None,
height_arg = ( offsets=[offsets] if offsets else None,
[heights_list[idx]] if heights_list[idx] is not None else None liquid_height=[height_to_bottom] if height_to_bottom else None,
) )
rate_arg = [rates_list[idx]] if rates_list[idx] is not None else None await self.custom_delay(seconds=1)
await self.dispense(
await self.aspirate( resources=[targets],
resources=[target], vols=[mix_vol],
vols=[mix_vol], flow_rates=[mix_rate] if mix_rate else None,
use_channels=use_channels, offsets=[offsets] if offsets else None,
flow_rates=rate_arg, liquid_height=[height_to_bottom] if height_to_bottom else None,
offsets=offset_arg, )
liquid_height=height_arg,
)
await self.custom_delay(seconds=1)
await self.dispense(
resources=[target],
vols=[mix_vol],
use_channels=use_channels,
flow_rates=rate_arg,
offsets=offset_arg,
liquid_height=height_arg,
)
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
"""Yield tips from a list of TipRacks one-by-one until depleted.""" """Yield tips from a list of TipRacks one-by-one until depleted."""

View File

@@ -30,11 +30,10 @@ from pylabrobot.liquid_handling.standard import (
ResourceMove, ResourceMove,
ResourceDrop, ResourceDrop,
) )
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack, create_homogeneous_resources, create_ordered_items_2d from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn
from unilabos.resources.itemized_carrier import ItemizedCarrier from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
class PRCXIError(RuntimeError): class PRCXIError(RuntimeError):
@@ -137,31 +136,13 @@ class PRCXI9300Plate(Plate):
# 使用 ordering 参数,只包含位置信息(键) # 使用 ordering 参数,只包含位置信息(键)
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else: else:
# ordering 的值是对象(可能是 Well 对象),检查是否有有效的 location # ordering 的值已经是对象,可以直接使用
# 如果是反序列化过程Well 对象可能没有正确的 location需要让 Plate 重新创建 items = ordering
sample_value = next(iter(ordering.values()), None) ordering_param = None
if sample_value is not None and hasattr(sample_value, 'location'):
# 如果是 Well 对象但 location 为 None说明是反序列化过程
# 让 Plate 自己创建 Well 对象
if sample_value.location is None:
items = None
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else:
# Well 对象有有效的 location可以直接使用
items = ordering
ordering_param = None
elif sample_value is None:
# ordering 的值都是 None让 Plate 自己创建 Well 对象
items = None
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else:
# 其他情况,直接使用
items = ordering
ordering_param = None
else: else:
items = None items = None
ordering_param = collections.OrderedDict() # 提供空的 ordering ordering_param = None
# 根据情况传递不同的参数 # 根据情况传递不同的参数
if items is not None: if items is not None:
super().__init__(name, size_x, size_y, size_z, super().__init__(name, size_x, size_y, size_z,
@@ -238,16 +219,9 @@ class PRCXI9300TipRack(TipRack):
# 使用 ordering 参数,只包含位置信息(键) # 使用 ordering 参数,只包含位置信息(键)
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else: else:
# ordering 的值已经是对象,需要过滤掉 None 值 # ordering 的值已经是对象,可以直接使用
# 只保留有效的对象,用于 ordered_items 参数 items = ordering
valid_items = {k: v for k, v in ordering.items() if v is not None} ordering_param = None
if valid_items:
items = valid_items
ordering_param = None
else:
# 如果没有有效对象,使用 ordering 参数
items = None
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else: else:
items = None items = None
ordering_param = None ordering_param = None
@@ -310,7 +284,7 @@ class PRCXI9300Trash(Trash):
""" """
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
category: str = "plate", category: str = "trash",
material_info: Optional[Dict[str, Any]] = None, material_info: Optional[Dict[str, Any]] = None,
**kwargs): **kwargs):
@@ -383,16 +357,9 @@ class PRCXI9300TubeRack(TubeRack):
# 使用 ordering 参数,只包含位置信息(键) # 使用 ordering 参数,只包含位置信息(键)
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else: else:
# ordering 的值已经是对象,需要过滤掉 None 值 # ordering 的值已经是对象,可以直接使用
# 只保留有效的对象,用于 ordered_items 参数 items_to_pass = ordering
valid_items = {k: v for k, v in ordering.items() if v is not None} ordering_param = None
if valid_items:
items_to_pass = valid_items
ordering_param = None
else:
# 如果没有有效对象,使用 ordering 参数
items_to_pass = None
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
elif items is not None: elif items is not None:
# 兼容旧的 items 参数 # 兼容旧的 items 参数
items_to_pass = items items_to_pass = items
@@ -448,81 +415,6 @@ class PRCXI9300TubeRack(TubeRack):
data.update(safe_state) data.update(safe_state)
return data return data
class PRCXI9300PlateAdapterSite(ItemizedCarrier):
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
material_info: Optional[Dict[str, Any]] = None, **kwargs):
# 处理 sites 参数的不同格式
sites = create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(0, 0, 0)],
resource_size_x=size_x,
resource_size_y=size_y,
resource_size_z=size_z,
name_prefix=name,
)[0]
# 确保不传递重复的参数
kwargs.pop('layout', None)
kwargs.pop('num_items_x', None)
kwargs.pop('num_items_y', None)
kwargs.pop('num_items_z', None)
kwargs.pop('sites', None)
super().__init__(name, size_x, size_y, size_z,
sites={name: sites},
num_items_x=1,
num_items_y=1,
num_items_z=1,
**kwargs)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def assign_child_resource(self, resource, location=Coordinate(0, 0, 0), reassign=True, spot=None):
"""重写 assign_child_resource 方法,对于适配器位置,不使用索引分配"""
# 直接调用 Resource 的 assign_child_resource避免 ItemizedCarrier 的索引逻辑
from pylabrobot.resources.resource import Resource
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
def unassign_child_resource(self, resource):
"""重写 unassign_child_resource 方法,对于适配器位置,不使用 sites 列表"""
# 直接调用 Resource 的 unassign_child_resource避免 ItemizedCarrier 的 sites 逻辑
from pylabrobot.resources.resource import Resource
Resource.unassign_child_resource(self, resource)
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
try:
data = super().serialize_state()
except AttributeError:
data = {}
# 包含 sites 配置信息,但避免序列化 ResourceHolder 对象
if hasattr(self, 'sites') and self.sites:
# 只保存 sites 的基本信息,不保存 ResourceHolder 对象本身
sites_info = []
for site in self.sites:
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
# 对于 pylabrobot 对象,只保存基本信息
sites_info.append({
"__pylabrobot_object__": True,
"class": site.__class__.__name__,
"module": site.__class__.__module__,
"name": getattr(site, 'name', str(site))
})
else:
sites_info.append(site)
data['sites'] = sites_info
return data
def load_state(self, state: Dict[str, Any]) -> None:
"""加载状态,包括 sites 配置信息"""
super().load_state(state)
# 从状态中恢复 sites 配置信息
if 'sites' in state:
self.sites = [state['sites']]
class PRCXI9300PlateAdapter(PlateAdapter): class PRCXI9300PlateAdapter(PlateAdapter):
""" """
@@ -921,7 +813,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
**backend_kwargs, **backend_kwargs,
): ):
res = await super().move_plate( return await super().move_plate(
plate, plate,
to, to,
intermediate_locations, intermediate_locations,
@@ -933,12 +825,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
target_plate_number = to, target_plate_number = to,
**backend_kwargs, **backend_kwargs,
) )
plate.unassign()
to.assign_child_resource(plate, location=Coordinate(0, 0, 0))
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
return res
class PRCXI9300Backend(LiquidHandlerBackend): class PRCXI9300Backend(LiquidHandlerBackend):
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。 """PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。

View File

@@ -4019,8 +4019,7 @@ liquid_handler:
mix_liquid_height: 0.0 mix_liquid_height: 0.0
mix_rate: 0 mix_rate: 0
mix_stage: '' mix_stage: ''
mix_times: mix_times: 0
- 0
mix_vol: 0 mix_vol: 0
none_keys: none_keys:
- '' - ''
@@ -4176,11 +4175,9 @@ liquid_handler:
mix_stage: mix_stage:
type: string type: string
mix_times: mix_times:
items: maximum: 2147483647
maximum: 2147483647 minimum: -2147483648
minimum: -2147483648 type: integer
type: integer
type: array
mix_vol: mix_vol:
maximum: 2147483647 maximum: 2147483647
minimum: -2147483648 minimum: -2147483648
@@ -5043,8 +5040,7 @@ liquid_handler.biomek:
mix_liquid_height: 0.0 mix_liquid_height: 0.0
mix_rate: 0 mix_rate: 0
mix_stage: '' mix_stage: ''
mix_times: mix_times: 0
- 0
mix_vol: 0 mix_vol: 0
none_keys: none_keys:
- '' - ''
@@ -5187,11 +5183,9 @@ liquid_handler.biomek:
mix_stage: mix_stage:
type: string type: string
mix_times: mix_times:
items: maximum: 2147483647
maximum: 2147483647 minimum: -2147483648
minimum: -2147483648 type: integer
type: integer
type: array
mix_vol: mix_vol:
maximum: 2147483647 maximum: 2147483647
minimum: -2147483648 minimum: -2147483648
@@ -9284,7 +9278,13 @@ liquid_handler.prcxi:
z: 0.0 z: 0.0
sample_id: '' sample_id: ''
type: '' type: ''
handles: {} handles:
input:
- data_key: wells
data_source: handle
data_type: resource
handler_key: input_wells
label: InputWells
placeholder_keys: placeholder_keys:
wells: unilabos_resources wells: unilabos_resources
result: {} result: {}
@@ -9671,8 +9671,7 @@ liquid_handler.prcxi:
mix_liquid_height: 0.0 mix_liquid_height: 0.0
mix_rate: 0 mix_rate: 0
mix_stage: '' mix_stage: ''
mix_times: mix_times: 0
- 0
mix_vol: 0 mix_vol: 0
none_keys: none_keys:
- '' - ''
@@ -9828,11 +9827,9 @@ liquid_handler.prcxi:
mix_stage: mix_stage:
type: string type: string
mix_times: mix_times:
items: maximum: 2147483647
maximum: 2147483647 minimum: -2147483648
minimum: -2147483648 type: integer
type: integer
type: array
mix_vol: mix_vol:
maximum: 2147483647 maximum: 2147483647
minimum: -2147483648 minimum: -2147483648

View File

@@ -124,17 +124,32 @@ class Registry:
"output": [ "output": [
{ {
"handler_key": "labware", "handler_key": "labware",
"label": "Labware",
"data_type": "resource", "data_type": "resource",
"data_source": "handle", "label": "Labware",
"data_key": "liquid", "data_source": "executor",
} "data_key": "created_resource_tree.@flatten",
},
{
"handler_key": "liquid_slots",
"data_type": "resource",
"label": "LiquidSlots",
"data_source": "executor",
"data_key": "liquid_input_resource_tree.@flatten",
},
{
"handler_key": "materials",
"data_type": "resource",
"label": "AllMaterials",
"data_source": "executor",
"data_key": "[created_resource_tree,liquid_input_resource_tree].@flatten.@flatten",
},
] ]
}, },
"placeholder_keys": { "placeholder_keys": {
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择 "res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择 "device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择 "parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
"class_name": "unilabos_class",
}, },
}, },
"test_latency": { "test_latency": {
@@ -186,7 +201,17 @@ class Registry:
"resources": "unilabos_resources", "resources": "unilabos_resources",
}, },
"goal_default": {}, "goal_default": {},
"handles": {}, "handles": {
"input": [
{
"handler_key": "input_resources",
"data_type": "resource",
"label": "InputResources",
"data_source": "handle",
"data_key": "resources", # 不为空
},
]
},
}, },
}, },
}, },

View File

@@ -27,7 +27,7 @@ class RegularContainer(Container):
def get_regular_container(name="container"): def get_regular_container(name="container"):
r = RegularContainer(name=name) r = RegularContainer(name=name)
r.category = "container" r.category = "container"
return RegularContainer(name=name) return r
# #
# class RegularContainer(object): # class RegularContainer(object):

View File

@@ -1151,11 +1151,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
if resource_class_config["type"] == "pylabrobot": if resource_class_config["type"] == "pylabrobot":
resource_plr = RESOURCE(name=resource_config["name"]) resource_plr = RESOURCE(name=resource_config["name"])
if resource_type != ResourcePLR: if resource_type != ResourcePLR:
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr]) tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True)
# r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None))
# # r = resource_plr_to_ulab(resource_plr=resource_plr)
# if resource_config.get("position") is not None:
# r["position"] = resource_config["position"]
r = tree_sets.dump() r = tree_sets.dump()
else: else:
r = resource_plr r = resource_plr

View File

@@ -1,7 +1,7 @@
import inspect import inspect
import traceback import traceback
import uuid import uuid
from pydantic import BaseModel, field_serializer, field_validator from pydantic import BaseModel, field_serializer, field_validator, ValidationError
from pydantic import Field from pydantic import Field
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
@@ -147,20 +147,24 @@ class ResourceDictInstance(object):
if not content.get("extra"): # MagicCode if not content.get("extra"): # MagicCode
content["extra"] = {} content["extra"] = {}
if "position" in content: if "position" in content:
pose = content.get("pose",{}) pose = content.get("pose", {})
if "position" not in pose : if "position" not in pose:
if "position" in content["position"]: if "position" in content["position"]:
pose["position"] = content["position"]["position"] pose["position"] = content["position"]["position"]
else: else:
pose["position"] = {"x": 0, "y": 0, "z": 0} pose["position"] = {"x": 0, "y": 0, "z": 0}
if "size" not in pose: if "size" not in pose:
pose["size"] = { pose["size"] = {
"width": content["config"].get("size_x", 0), "width": content["config"].get("size_x", 0),
"height": content["config"].get("size_y", 0), "height": content["config"].get("size_y", 0),
"depth": content["config"].get("size_z", 0) "depth": content["config"].get("size_z", 0),
} }
content["pose"] = pose content["pose"] = pose
return ResourceDictInstance(ResourceDict.model_validate(content)) try:
res_dict = ResourceDict.model_validate(content)
return ResourceDictInstance(res_dict)
except ValidationError as err:
raise err
def get_plr_nested_dict(self) -> Dict[str, Any]: def get_plr_nested_dict(self) -> Dict[str, Any]:
"""获取资源实例的嵌套字典表示""" """获取资源实例的嵌套字典表示"""
@@ -322,7 +326,7 @@ class ResourceTreeSet(object):
) )
@classmethod @classmethod
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet": def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
""" """
从plr资源创建ResourceTreeSet 从plr资源创建ResourceTreeSet
""" """
@@ -339,6 +343,8 @@ class ResourceTreeSet(object):
} }
if source in replace_info: if source in replace_info:
return replace_info[source] return replace_info[source]
elif source is None:
return ""
else: else:
print("转换pylabrobot的时候出现未知类型", source) print("转换pylabrobot的时候出现未知类型", source)
return source return source
@@ -349,7 +355,8 @@ class ResourceTreeSet(object):
if not uid: if not uid:
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
res.unilabos_uuid = uid res.unilabos_uuid = uid
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}") if not known_newly_created:
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}")
# 获取unilabos_extra默认为空字典 # 获取unilabos_extra默认为空字典
extra = getattr(res, "unilabos_extra", {}) extra = getattr(res, "unilabos_extra", {})
@@ -448,7 +455,13 @@ class ResourceTreeSet(object):
from pylabrobot.utils.object_parsing import find_subclass from pylabrobot.utils.object_parsing import find_subclass
# 类型映射 # 类型映射
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"} TYPE_MAP = {
"plate": "Plate",
"well": "Well",
"deck": "Deck",
"container": "RegularContainer",
"tip_spot": "TipSpot",
}
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict): def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra""" """一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
@@ -918,6 +931,33 @@ class DeviceNodeResourceTracker(object):
return self._traverse_and_process(resource, process) return self._traverse_and_process(resource, process)
def loop_find_with_uuid(self, resource, target_uuid: str):
"""
递归遍历资源树,根据 uuid 查找并返回对应的资源
Args:
resource: 资源对象可以是list、dict或实例
target_uuid: 要查找的uuid
Returns:
找到的资源对象未找到则返回None
"""
found_resource = None
def process(res):
nonlocal found_resource
if found_resource is not None:
return 0 # 已找到,跳过后续处理
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
if current_uuid and current_uuid == target_uuid:
found_resource = res
logger.trace(f"找到资源UUID: {target_uuid}")
return 1
return 0
self._traverse_and_process(resource, process)
return found_resource
def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int: def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int:
""" """
递归遍历资源树,根据 name 设置所有节点的 extra 递归遍历资源树,根据 name 设置所有节点的 extra
@@ -1103,7 +1143,7 @@ class DeviceNodeResourceTracker(object):
for key in keys_to_remove: for key in keys_to_remove:
self.resource2parent_resource.pop(key, None) self.resource2parent_resource.pop(key, None)
logger.debug(f"成功移除资源: {resource}") logger.trace(f"[ResourceTracker] 成功移除资源: {resource}")
return True return True
def clear_resource(self): def clear_resource(self):

View File

@@ -159,10 +159,14 @@ _msg_converter: Dict[Type, Any] = {
else Pose() else Pose()
), ),
config=json.dumps(x.get("config", {})), config=json.dumps(x.get("config", {})),
data=json.dumps(x.get("data", {})), data=json.dumps(obtain_data_with_uuid(x)),
), ),
} }
def obtain_data_with_uuid(x: dict):
data = x.get("data", {})
data["unilabos_uuid"] = x.get("uuid", None)
return data
def json_or_yaml_loads(data: str) -> Any: def json_or_yaml_loads(data: str) -> Any:
try: try:

View File

@@ -392,9 +392,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
parent_resource = self.resource_tracker.figure_resource( parent_resource = self.resource_tracker.figure_resource(
{"name": bind_parent_id} {"name": bind_parent_id}
) )
for r in rts.root_nodes: for r in rts.root_nodes:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
r.res_content.parent_uuid = parent_resource.unilabos_uuid r.res_content.parent_uuid = parent_resource.unilabos_uuid
else:
for r in rts.root_nodes:
r.res_content.parent_uuid = self.uuid
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer): if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
# noinspection PyTypeChecker # noinspection PyTypeChecker
@@ -430,11 +433,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
}) })
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) uuid_maps = json.loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) plr_instances = rts.to_plr_resources()
for plr_instance in plr_instances:
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
rts: ResourceTreeSet = ResourceTreeSet.from_plr_resources(plr_instances)
self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
final_response = { final_response = {
"created_resources": rts.dump(), "created_resource_tree": rts.dump(),
"liquid_input_resources": [], "liquid_input_resource_tree": [],
} }
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
# 如果driver自己就有assign的方法那就使用driver自己的assign方法 # 如果driver自己就有assign的方法那就使用driver自己的assign方法
@@ -460,7 +466,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res return res
try: try:
if len(rts.root_nodes) == 1 and parent_resource is not None: if len(rts.root_nodes) == 1 and parent_resource is not None:
plr_instance = rts.to_plr_resources()[0] plr_instance = plr_instances[0]
if isinstance(plr_instance, Plate): if isinstance(plr_instance, Plate):
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
@@ -485,7 +491,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = [] input_wells = []
for r in LIQUID_INPUT_SLOT: for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r]) input_wells.append(plr_instance.children[r])
final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump() final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param: if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
other_calling_param["slot"] = int(other_calling_param["slot"]) other_calling_param["slot"] = int(other_calling_param["slot"])
@@ -653,61 +659,71 @@ class BaseROS2DeviceNode(Node, Generic[T]):
def transfer_to_new_resource( def transfer_to_new_resource(
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any] self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
): ) -> Optional["ResourcePLR"]:
parent_uuid = tree.root_node.res_content.parent_uuid parent_uuid = tree.root_node.res_content.parent_uuid
if parent_uuid: if not parent_uuid:
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid) self.lab_logger().warning(
if parent_resource is None: f"物料{plr_resource} parent未知挂载到当前节点下额外参数{additional_add_params}"
)
return None
if parent_uuid == self.uuid:
self.lab_logger().warning(
f"物料{plr_resource}请求挂载到{self.identifier},额外参数:{additional_add_params}"
)
return None
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
if parent_resource is None:
self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
)
else:
try:
# 特殊兼容所有plr的物料的assign方法和create_resource append_resource后期同步
additional_params = {}
extra = getattr(plr_resource, "unilabos_extra", {})
if len(extra):
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
if "update_resource_site" in extra:
additional_add_params["site"] = extra["update_resource_site"]
site = additional_add_params.get("site", None)
spec = inspect.signature(parent_resource.assign_child_resource)
if "spot" in spec.parameters:
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
if ordering_dict:
site = list(ordering_dict.keys()).index(site)
additional_params["spot"] = site
old_parent = plr_resource.parent
if old_parent is not None:
# plr并不支持同一个deck的加载和卸载
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
old_parent.unassign_child_resource(plr_resource)
self.lab_logger().warning( self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在" f"物料{plr_resource}请求挂载{parent_resource},额外参数:{additional_params}"
) )
else:
try:
# 特殊兼容所有plr的物料的assign方法和create_resource append_resource后期同步
additional_params = {}
extra = getattr(plr_resource, "unilabos_extra", {})
if len(extra):
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
if "update_resource_site" in extra:
additional_add_params["site"] = extra["update_resource_site"]
site = additional_add_params.get("site", None)
spec = inspect.signature(parent_resource.assign_child_resource)
if "spot" in spec.parameters:
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
if ordering_dict:
site = list(ordering_dict.keys()).index(site)
additional_params["spot"] = site
old_parent = plr_resource.parent
if old_parent is not None:
# plr并不支持同一个deck的加载和卸载
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
old_parent.unassign_child_resource(plr_resource)
self.lab_logger().warning(
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
)
# ⭐ assign 之前,需要从 resources 列表中移除 # ⭐ assign 之前,需要从 resources 列表中移除
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源 # 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
# 如果不移除figure_resource 会找到两次:一次在 resources一次在 parent 的 children # 如果不移除figure_resource 会找到两次:一次在 resources一次在 parent 的 children
resource_id = id(plr_resource) resource_id = id(plr_resource)
for i, r in enumerate(self.resource_tracker.resources): for i, r in enumerate(self.resource_tracker.resources):
if id(r) == resource_id: if id(r) == resource_id:
self.resource_tracker.resources.pop(i) self.resource_tracker.resources.pop(i)
self.lab_logger().debug( self.lab_logger().debug(
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)" f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
) )
break break
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params) parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
func = getattr(self.driver_instance, "resource_tree_transfer", None) func = getattr(self.driver_instance, "resource_tree_transfer", None)
if callable(func): if callable(func):
# 分别是 物料的原来父节点当前物料的状态物料的新父节点此时物料已经重新assign了 # 分别是 物料的原来父节点当前物料的状态物料的新父节点此时物料已经重新assign了
func(old_parent, plr_resource, parent_resource) func(old_parent, plr_resource, parent_resource)
except Exception as e: return parent_resource
self.lab_logger().warning( except Exception as e:
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}" self.lab_logger().warning(
) f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
)
async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response): async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
""" """
@@ -722,7 +738,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
def _handle_add( def _handle_add(
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
) -> Dict[str, Any]: ) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
""" """
处理资源添加操作的内部函数 处理资源添加操作的内部函数
@@ -734,15 +750,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Returns: Returns:
操作结果字典 操作结果字典
""" """
parents = [] # 放的是被变更的物料 / 被变更的物料父级
for plr_resource, tree in zip(plr_resources, tree_set.trees): for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.resource_tracker.add_resource(plr_resource) self.resource_tracker.add_resource(plr_resource)
self.transfer_to_new_resource(plr_resource, tree, additional_add_params) parent = self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
if parent is not None:
parents.append(parent)
else:
parents.append(plr_resource)
func = getattr(self.driver_instance, "resource_tree_add", None) func = getattr(self.driver_instance, "resource_tree_add", None)
if callable(func): if callable(func):
func(plr_resources) func(plr_resources)
return {"success": True, "action": "add"} return {"success": True, "action": "add"}, parents
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]: def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
""" """
@@ -777,11 +798,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if plr_resource.parent is not None: if plr_resource.parent is not None:
plr_resource.parent.unassign_child_resource(plr_resource) plr_resource.parent.unassign_child_resource(plr_resource)
self.resource_tracker.remove_resource(plr_resource) self.resource_tracker.remove_resource(plr_resource)
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") self.lab_logger().info(f"[资源同步] 移除物料 {plr_resource} 及其子节点")
for other_plr_resource in other_plr_resources: for other_plr_resource in other_plr_resources:
self.resource_tracker.remove_resource(other_plr_resource) self.resource_tracker.remove_resource(other_plr_resource)
self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") self.lab_logger().info(f"[资源同步] 移除物料 {other_plr_resource} 及其子节点")
return { return {
"success": True, "success": True,
@@ -813,11 +834,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
original_instance: ResourcePLR = self.resource_tracker.figure_resource( original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False {"uuid": tree.root_node.res_content.uuid}, try_mode=False
) )
original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
old_name = original_instance.name
new_name = plr_resource.name
parent_appended = False
# Update操作中包含改名需要先remove再add # Update操作中包含改名需要先remove再add,这里更新父节点即可
if original_instance.name != plr_resource.name: if not not_same_parent and old_name != new_name:
old_name = original_instance.name
new_name = plr_resource.name
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
# 收集所有相关的uuid包括子节点 # 收集所有相关的uuid包括子节点
@@ -826,12 +852,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
_handle_add([original_instance], tree_set, additional_add_params) _handle_add([original_instance], tree_set, additional_add_params)
self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}")
original_instances.append(original_parent_resource)
parent_appended = True
# 常规更新:不涉及改名 # 常规更新:不涉及改名
original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
self.lab_logger().info( self.lab_logger().info(
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} " f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
f"目标父节点{target_parent_resource_uuid} 更新" f"目标父节点{target_parent_resource_uuid} 更新"
@@ -842,13 +866,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501 original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
# 如果父节点变化,需要重新挂载 # 如果父节点变化,需要重新挂载
if ( if not_same_parent:
original_parent_resource_uuid != target_parent_resource_uuid parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
and original_parent_resource is not None original_instances.append(parent)
): parent_appended = True
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
else: else:
# 判断是否变更了resource_site # 判断是否变更了resource_site,重新登记
target_site = original_instance.unilabos_extra.get("update_resource_site") target_site = original_instance.unilabos_extra.get("update_resource_site")
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else [] site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
@@ -856,7 +879,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
site_index = sites.index(original_instance) site_index = sites.index(original_instance)
site_name = site_names[site_index] site_name = site_names[site_index]
if site_name != target_site: if site_name != target_site:
self.transfer_to_new_resource(original_instance, tree, additional_add_params) parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
if parent is not None:
original_instances.append(parent)
parent_appended = True
# 加载状态 # 加载状态
original_instance.load_all_state(states) original_instance.load_all_state(states)
@@ -864,7 +890,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info( self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}" f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}"
) )
original_instances.append(original_instance) if not parent_appended:
original_instances.append(original_instance)
# 调用driver的update回调 # 调用driver的update回调
func = getattr(self.driver_instance, "resource_tree_update", None) func = getattr(self.driver_instance, "resource_tree_update", None)
@@ -881,8 +908,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
action = i.get("action") # remove, add, update action = i.get("action") # remove, add, update
resources_uuid: List[str] = i.get("data") # 资源数据 resources_uuid: List[str] = i.get("data") # 资源数据
additional_add_params = i.get("additional_add_params", {}) # 额外参数 additional_add_params = i.get("additional_add_params", {}) # 额外参数
self.lab_logger().info( self.lab_logger().trace(
f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}" f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
) )
tree_set = None tree_set = None
if action in ["add", "update"]: if action in ["add", "update"]:
@@ -894,8 +921,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if tree_set is None: if tree_set is None:
raise ValueError("tree_set不能为None") raise ValueError("tree_set不能为None")
plr_resources = tree_set.to_plr_resources() plr_resources = tree_set.to_plr_resources()
result = _handle_add(plr_resources, tree_set, additional_add_params) result, parents = _handle_add(plr_resources, tree_set, additional_add_params)
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources) parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None]
# de_dupe_parents = list(set(parents))
# Fix unhashable type error for WareHouse
de_dupe_parents = []
_seen_ids = set()
for p in parents:
if id(p) not in _seen_ids:
_seen_ids.add(id(p))
de_dupe_parents.append(p)
new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重
for tree in new_tree_set.trees:
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
tree.root_node.res_content.parent_uuid = self.uuid
r = SerialCommand.Request() r = SerialCommand.Request()
r.command = json.dumps( r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
@@ -914,7 +953,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
if not BasicConfig.no_update_feedback: if not BasicConfig.no_update_feedback:
new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) # 去重
for tree in new_tree_set.trees:
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
tree.root_node.res_content.parent_uuid = self.uuid
r = SerialCommand.Request() r = SerialCommand.Request()
r.command = json.dumps( r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
@@ -934,15 +976,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 返回处理结果 # 返回处理结果
result_json = {"results": results, "total": len(data)} result_json = {"results": results, "total": len(data)}
res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder) res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder)
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations") # self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
error_msg = f"Invalid JSON format: {str(e)}" error_msg = f"Invalid JSON format: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}") self.lab_logger().error(f"[资源同步] {error_msg}")
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
except Exception as e: except Exception as e:
error_msg = f"Unexpected error: {str(e)}" error_msg = f"Unexpected error: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}") self.lab_logger().error(f"[资源同步] {error_msg}")
self.lab_logger().error(traceback.format_exc()) self.lab_logger().error(traceback.format_exc())
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
@@ -1263,7 +1305,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name) ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}") self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {str(action_kwargs)[:1000]}")
self.lab_logger().trace(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
error_skip = False error_skip = False
# 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过 # 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过
if action_name not in ["create_resource_detailed", "create_resource"]: if action_name not in ["create_resource_detailed", "create_resource"]:
@@ -1279,9 +1322,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 批量查询资源 # 批量查询资源
queried_resources = [] queried_resources = []
for resource_data in resource_inputs: for resource_data in resource_inputs:
plr_resource = await self.get_resource_with_dir( unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
resource_id=resource_data["id"], with_children=True if unilabos_uuid is None:
) plr_resource = await self.get_resource_with_dir(
resource_id=resource_data["id"], with_children=True
)
else:
resource_tree = await self.get_resource([unilabos_uuid])
plr_resource = resource_tree.to_plr_resources()[0]
if "sample_id" in resource_data: if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
queried_resources.append(plr_resource) queried_resources.append(plr_resource)
@@ -1330,9 +1378,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
execution_success = True execution_success = True
except Exception as _: except Exception as _:
execution_error = traceback.format_exc() execution_error = traceback.format_exc()
error( error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
)
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
@@ -1352,8 +1399,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
except Exception as _: except Exception as _:
execution_error = traceback.format_exc() execution_error = traceback.format_exc()
error( error(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
) trace(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
@@ -1421,7 +1469,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for r in rs: for r in rs:
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象 res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
else: else:
res = self.resource_tracker.parent_resource(r) res = self.resource_tracker.parent_resource(rs)
if id(res) not in seen: if id(res) not in seen:
seen.add(id(res)) seen.add(id(res))
unique_resources.append(res) unique_resources.append(res)
@@ -1497,8 +1545,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
resource_data = function_args[arg_name] resource_data = function_args[arg_name]
if isinstance(resource_data, dict) and "id" in resource_data: if isinstance(resource_data, dict) and "id" in resource_data:
try: try:
converted_resource = self._convert_resource_sync(resource_data) function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
function_args[arg_name] = converted_resource
except Exception as e: except Exception as e:
self.lab_logger().error( self.lab_logger().error(
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
@@ -1512,12 +1559,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
resource_list = function_args[arg_name] resource_list = function_args[arg_name]
if isinstance(resource_list, list): if isinstance(resource_list, list):
try: try:
converted_resources = [] uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
for resource_data in resource_list: function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
if isinstance(resource_data, dict) and "id" in resource_data:
converted_resource = self._convert_resource_sync(resource_data)
converted_resources.append(converted_resource)
function_args[arg_name] = converted_resources
except Exception as e: except Exception as e:
self.lab_logger().error( self.lab_logger().error(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
@@ -1530,20 +1573,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
) )
def _convert_resource_sync(self, resource_data: Dict[str, Any]): def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]:
"""同步转换资源数据为实例""" """同步转换资源 UUID 为实例
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps(
{
"id": resource_data.get("id", None),
"uuid": resource_data.get("uuid", None),
"with_children": True,
}
)
# 同步调用资源查询服务 Args:
future = self._resource_clients["resource_get"].call_async(r) *uuids: 一个或多个资源 UUID
Returns:
单个 UUID 时返回单个资源实例,多个 UUID 时返回资源实例列表
"""
if not uuids:
raise ValueError("至少需要提供一个 UUID")
uuids_list = list(uuids)
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
command=json.dumps(
{
"data": {"data": uuids_list, "with_children": True},
"action": "get",
}
)
))
# 等待结果使用while循环每次sleep 0.05秒最多等待30秒 # 等待结果使用while循环每次sleep 0.05秒最多等待30秒
timeout = 30.0 timeout = 30.0
@@ -1553,27 +1603,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
elapsed += 0.05 elapsed += 0.05
if not future.done(): if not future.done():
raise Exception(f"资源查询超时: {resource_data}") raise Exception(f"资源查询超时: {uuids_list}")
response = future.result() response = future.result()
if response is None: if response is None:
raise Exception(f"资源查询返回空结果: {resource_data}") raise Exception(f"资源查询返回空结果: {uuids_list}")
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0] if not len(tree_set.trees):
raise Exception(f"资源查询返回空树: {raw_data}")
plr_resources = tree_set.to_plr_resources()
# 通过资源跟踪器获取本地实例 # 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) figured_resources: List[ResourcePLR] = []
if len(res) == 0: for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例") res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
return plr_resource if len(res) == 0:
elif len(res) == 1: self.lab_logger().warning(f"资源转换未能索引到实例: {tree.root_node.res_content},返回新建实例")
return res[0] figured_resources.append(plr_resource)
else: elif len(res) == 1:
raise ValueError(f"资源转换得到多个实例: {res}") figured_resources.append(res[0])
else:
raise ValueError(f"资源转换得到多个实例: {res}")
mapped_plr_resources = []
for uuid in uuids_list:
for plr_resource in figured_resources:
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
mapped_plr_resources.append(r)
break
return mapped_plr_resources
async def _execute_driver_command_async(self, string: str): async def _execute_driver_command_async(self, string: str):
try: try:

View File

@@ -23,6 +23,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import initialize_resource from unilabos.resources.graphio import initialize_resource
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
@@ -361,8 +362,7 @@ class HostNode(BaseROS2DeviceNode):
request.command = "" request.command = ""
future = sclient.call_async(request) future = sclient.call_async(request)
# Use timeout for result as well # Use timeout for result as well
future.result(timeout_sec=5.0) future.result()
self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}")
except Exception as e: except Exception as e:
# Gracefully handle destruction during shutdown # Gracefully handle destruction during shutdown
if "destruction was requested" in str(e) or self._shutting_down: if "destruction was requested" in str(e) or self._shutting_down:
@@ -586,11 +586,10 @@ class HostNode(BaseROS2DeviceNode):
) )
try: try:
new_li = [] assert len(response) == 1, "Create Resource应当只返回一个结果"
for i in response: for i in response:
res = json.loads(i) res = json.loads(i)
new_li.append(res) return res
return {"resources": new_li, "liquid_input_resources": new_li}
except Exception as ex: except Exception as ex:
pass pass
_n = "\n" _n = "\n"
@@ -795,7 +794,8 @@ class HostNode(BaseROS2DeviceNode):
assign_sample_id(action_kwargs) assign_sample_id(action_kwargs)
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}") self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
action_client.wait_for_server() action_client.wait_for_server()
goal_uuid_obj = UUID(uuid=list(u.bytes)) goal_uuid_obj = UUID(uuid=list(u.bytes))
@@ -1133,11 +1133,11 @@ class HostNode(BaseROS2DeviceNode):
接收序列化的 ResourceTreeSet 数据并进行处理 接收序列化的 ResourceTreeSet 数据并进行处理
""" """
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
try: try:
# 解析请求数据 # 解析请求数据
data = json.loads(request.command) data = json.loads(request.command)
action = data["action"] action = data["action"]
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
data = data["data"] data = data["data"]
if action == "add": if action == "add":
await self._resource_tree_action_add_callback(data, response) await self._resource_tree_action_add_callback(data, response)
@@ -1243,7 +1243,7 @@ class HostNode(BaseROS2DeviceNode):
data = json.loads(request.command) data = json.loads(request.command)
if "uuid" in data and data["uuid"] is not None: if "uuid" in data and data["uuid"] is not None:
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"]) http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
elif "id" in data and data["id"].startswith("/"): elif "id" in data:
http_req = http_client.resource_get(data["id"], data["with_children"]) http_req = http_client.resource_get(data["id"], data["with_children"])
else: else:
raise ValueError("没有使用正确的物料 id 或 uuid") raise ValueError("没有使用正确的物料 id 或 uuid")
@@ -1453,10 +1453,16 @@ class HostNode(BaseROS2DeviceNode):
} }
def test_resource( def test_resource(
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot] self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
) -> TestResourceReturn: ) -> TestResourceReturn:
if resources is None:
resources = []
if devices is None:
devices = []
if resource is None:
resource = RegularContainer("test_resource传入None")
return { return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(), "resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
"devices": [device, *devices], "devices": [device, *devices],
} }
@@ -1508,7 +1514,7 @@ class HostNode(BaseROS2DeviceNode):
# 构建服务地址 # 构建服务地址
srv_address = f"/srv{namespace}/s2c_resource_tree" srv_address = f"/srv{namespace}/s2c_resource_tree"
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation") self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
# 创建服务客户端 # 创建服务客户端
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
@@ -1543,9 +1549,7 @@ class HostNode(BaseROS2DeviceNode):
time.sleep(0.05) time.sleep(0.05)
response = future.result() response = future.result()
self.lab_logger().info( self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
)
return True return True
except Exception as e: except Exception as e:

View File

@@ -8,8 +8,8 @@
"parent": "", "parent": "",
"pose": { "pose": {
"size": { "size": {
"width": 542, "width": 562,
"height": 374, "height": 394,
"depth": 0 "depth": 0
} }
}, },
@@ -21,12 +21,12 @@
}, },
"host": "10.20.30.184", "host": "10.20.30.184",
"port": 9999, "port": 9999,
"debug": false, "debug": true,
"setup": true, "setup": true,
"is_9320": true, "is_9320": true,
"timeout": 10, "timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"simulator": false, "simulator": true,
"channel_num": 2 "channel_num": 2
}, },
"data": { "data": {
@@ -37,7 +37,7 @@
"model": null, "model": null,
"position": { "position": {
"x": 0, "x": 0,
"y": 700, "y": 240,
"z": 0 "z": 0
} }
}, },
@@ -50,8 +50,8 @@
"type": "deck", "type": "deck",
"class": "", "class": "",
"position": { "position": {
"x": 0, "x": 10,
"y": 0, "y": 10,
"z": 0 "z": 0
}, },
"config": { "config": {
@@ -83,7 +83,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -96,7 +96,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T1", "label": "T1",
@@ -128,7 +128,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -141,7 +141,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T2", "label": "T2",
@@ -173,7 +173,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -186,7 +186,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T3", "label": "T3",
@@ -218,7 +218,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -231,7 +231,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T4", "label": "T4",
@@ -263,7 +263,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -276,7 +276,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T5", "label": "T5",
@@ -308,7 +308,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -321,7 +321,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T6", "label": "T6",
@@ -353,7 +353,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -366,7 +366,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T7", "label": "T7",
@@ -398,7 +398,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -411,7 +411,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T8", "label": "T8",
@@ -443,7 +443,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -456,7 +456,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T9", "label": "T9",
@@ -488,7 +488,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -501,7 +501,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T10", "label": "T10",
@@ -533,7 +533,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -546,7 +546,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T11", "label": "T11",
@@ -578,7 +578,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -591,7 +591,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T12", "label": "T12",
@@ -623,7 +623,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -636,7 +636,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T13", "label": "T13",
@@ -668,7 +668,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -681,7 +681,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T14", "label": "T14",
@@ -713,7 +713,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -726,7 +726,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T15", "label": "T15",
@@ -758,7 +758,7 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"type": "PRCXI9300PlateAdapterSite", "type": "PRCXI9300Container",
"size_x": 127, "size_x": 127,
"size_y": 85.5, "size_y": 85.5,
"size_z": 10, "size_z": 10,
@@ -771,7 +771,7 @@
"category": "plate", "category": "plate",
"model": null, "model": null,
"barcode": null, "barcode": null,
"ordering": {},
"sites": [ "sites": [
{ {
"label": "T16", "label": "T16",
@@ -40712,7 +40712,7 @@
"children": [], "children": [],
"parent": "T16", "parent": "T16",
"type": "trash", "type": "plate",
"class": "", "class": "",
"position": { "position": {
"x": 0, "x": 0,