diff --git a/docs/conf.py b/docs/conf.py index c6b7d50a..f15f0e6f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings "sphinx_rtd_theme", + "sphinxcontrib.mermaid" ] source_suffix = { @@ -42,6 +43,8 @@ myst_enable_extensions = [ "substitution", ] +myst_fence_as_directive = ["mermaid"] + templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] @@ -203,3 +206,5 @@ def generate_action_includes(app): def setup(app): app.connect("builder-inited", generate_action_includes) + app.add_js_file("https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js") + app.add_js_file(None, body="mermaid.initialize({startOnLoad:true});") diff --git a/docs/developer_guide/action_includes.md b/docs/developer_guide/action_includes.md index 44403eb5..ee145bfb 100644 --- a/docs/developer_guide/action_includes.md +++ b/docs/developer_guide/action_includes.md @@ -1,88 +1,26 @@ ## 简单单变量动作函数 + ### `SendCmd` ```{literalinclude} ../../unilabos_msgs/action/SendCmd.action :language: yaml ``` ---- - -### `StrSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action -:language: yaml -``` - ---- - -### `IntSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action -:language: yaml -``` - ---- - -### `FloatSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/FloatSingleInput.action -:language: yaml -``` - ---- - -### `Point3DSeparateInput` - -```{literalinclude} ../../unilabos_msgs/action/Point3DSeparateInput.action -:language: yaml -``` - ---- - -### `Wait` - -```{literalinclude} ../../unilabos_msgs/action/Wait.action -:language: yaml -``` - ---- - +---- ## 常量有机化学操作 Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。 + + ### `Clean` ```{literalinclude} ../../unilabos_msgs/action/Clean.action :language: yaml ``` ---- - -### `EvacuateAndRefill` - -```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action -:language: yaml -``` - ---- - -### `Evaporate` - -```{literalinclude} ../../unilabos_msgs/action/Evaporate.action -:language: yaml -``` - ---- - -### `HeatChill` - -```{literalinclude} ../../unilabos_msgs/action/HeatChill.action -:language: yaml -``` - ---- +---- ### `HeatChillStart` @@ -90,7 +28,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab :language: yaml ``` ---- +---- ### `HeatChillStop` @@ -98,7 +36,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab :language: yaml ``` ---- +---- ### `PumpTransfer` @@ -106,195 +44,12 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab :language: yaml ``` ---- - -### `Separate` - -```{literalinclude} ../../unilabos_msgs/action/Separate.action -:language: yaml -``` - ---- - -### `Stir` - -```{literalinclude} ../../unilabos_msgs/action/Stir.action -:language: yaml -``` - ---- - -### `Add` - -```{literalinclude} ../../unilabos_msgs/action/Add.action -:language: yaml -``` - ---- - -### `AddSolid` - -```{literalinclude} ../../unilabos_msgs/action/AddSolid.action -:language: yaml -``` - ---- - -### `AdjustPH` - -```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action -:language: yaml -``` - ---- - -### `Centrifuge` - -```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action -:language: yaml -``` - ---- - -### `CleanVessel` - -```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action -:language: yaml -``` - ---- - -### `Crystallize` - -```{literalinclude} ../../unilabos_msgs/action/Crystallize.action -:language: yaml -``` - ---- - -### `Dissolve` - -```{literalinclude} ../../unilabos_msgs/action/Dissolve.action -:language: yaml -``` - ---- - -### `Dry` - -```{literalinclude} ../../unilabos_msgs/action/Dry.action -:language: yaml -``` - ---- - -### `Filter` - -```{literalinclude} ../../unilabos_msgs/action/Filter.action -:language: yaml -``` - ---- - -### `FilterThrough` - -```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action -:language: yaml -``` - ---- - -### `Hydrogenate` - -```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action -:language: yaml -``` - ---- - -### `Purge` - -```{literalinclude} ../../unilabos_msgs/action/Purge.action -:language: yaml -``` - ---- - -### `Recrystallize` - -```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action -:language: yaml -``` - ---- - -### `RunColumn` - -```{literalinclude} ../../unilabos_msgs/action/RunColumn.action -:language: yaml -``` - ---- - -### `StartPurge` - -```{literalinclude} ../../unilabos_msgs/action/StartPurge.action -:language: yaml -``` - ---- - -### `StartStir` - -```{literalinclude} ../../unilabos_msgs/action/StartStir.action -:language: yaml -``` - ---- - -### `StopPurge` - -```{literalinclude} ../../unilabos_msgs/action/StopPurge.action -:language: yaml -``` - ---- - -### `StopStir` - -```{literalinclude} ../../unilabos_msgs/action/StopStir.action -:language: yaml -``` - ---- - -### `Transfer` - -```{literalinclude} ../../unilabos_msgs/action/Transfer.action -:language: yaml -``` - ---- - -### `WashSolid` - -```{literalinclude} ../../unilabos_msgs/action/WashSolid.action -:language: yaml -``` - ---- - +---- ## 移液工作站及相关生物自动化设备操作 Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。 -### `LiquidHandlerAspirate` -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action -:language: yaml -``` - ---- ### `LiquidHandlerDiscardTips` @@ -302,15 +57,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerDispense` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDispense.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerDropTips` @@ -318,7 +65,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerDropTips96` @@ -326,7 +73,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMoveLid` @@ -334,7 +81,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMovePlate` @@ -342,7 +89,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMoveResource` @@ -350,7 +97,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerPickUpTips` @@ -358,7 +105,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerPickUpTips96` @@ -366,7 +113,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerReturnTips` @@ -374,7 +121,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerReturnTips96` @@ -382,7 +129,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerStamp` @@ -390,129 +137,17 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerTransfer` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action -:language: yaml -``` - ---- - -### `LiquidHandlerAdd` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action -:language: yaml -``` - ---- - -### `LiquidHandlerIncubateBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerIncubateBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerMix` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMix.action -:language: yaml -``` - ---- - -### `LiquidHandlerMoveBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerMoveTo` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveTo.action -:language: yaml -``` - ---- - -### `LiquidHandlerOscillateBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerOscillateBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerProtocolCreation` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerProtocolCreation.action -:language: yaml -``` - ---- - -### `LiquidHandlerRemove` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerRemove.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetGroup` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetGroup.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetLiquid` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetLiquid.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetTipRack` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetTipRack.action -:language: yaml -``` - ---- - -### `LiquidHandlerTransferBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerTransferGroup` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferGroup.action -:language: yaml -``` - ---- - +---- ## 多工作站及小车运行、物料转移 + ### `AGVTransfer` ```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action :language: yaml ``` ---- +---- ### `WorkStationRun` @@ -520,64 +155,12 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `ResetHandling` - -```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action -:language: yaml -``` - ---- - -### `ResourceCreateFromOuter` - -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action -:language: yaml -``` - ---- - -### `ResourceCreateFromOuterEasy` - -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action -:language: yaml -``` - ---- - -### `SetPumpPosition` - -```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action -:language: yaml -``` - ---- - -## 固体分配与处理设备操作 - -### `SolidDispenseAddPowderTube` - -```{literalinclude} ../../unilabos_msgs/action/SolidDispenseAddPowderTube.action -:language: yaml -``` - ---- - -## 其他设备操作 - -### `EmptyIn` - -```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action -:language: yaml -``` - ---- - +---- ## 机械臂、夹爪等机器人设备 Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`: + ### `FollowJointTrajectory` ```yaml @@ -645,8 +228,7 @@ trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error ``` ---- - +---- ### `GripperCommand` ```yaml @@ -664,19 +246,42 @@ bool reached_goal # True iff the gripper position has reached the commanded setp ``` ---- - +---- ### `JointTrajectory` ```yaml trajectory_msgs/JointTrajectory trajectory --- - --- + ``` ---- +---- +### `ParallelGripperCommand` +```yaml +# Parallel grippers refer to an end effector where two opposing fingers grasp an object from opposite sides. +sensor_msgs/JointState command +# name: the name(s) of the joint this command is requesting +# position: desired position of each gripper joint (radians or meters) +# velocity: (optional, not used if empty) max velocity of the joint allowed while moving (radians or meters / second) +# effort: (optional, not used if empty) max effort of the joint allowed while moving (Newtons or Newton-meters) +--- +sensor_msgs/JointState state # The current gripper state. +# position of each joint (radians or meters) +# optional: velocity of each joint (radians or meters / second) +# optional: effort of each joint (Newtons or Newton-meters) +bool stalled # True if the gripper is exerting max effort and not moving +bool reached_goal # True if the gripper position has reached the commanded setpoint +--- +sensor_msgs/JointState state # The current gripper state. +# position of each joint (radians or meters) +# optional: velocity of each joint (radians or meters / second) +# optional: effort of each joint (Newtons or Newton-meters) + +``` + +---- ### `PointHead` ```yaml @@ -686,13 +291,12 @@ string pointing_frame builtin_interfaces/Duration min_duration float64 max_velocity --- - --- float64 pointing_angle_error + ``` ---- - +---- ### `SingleJointPosition` ```yaml @@ -700,16 +304,15 @@ float64 position builtin_interfaces/Duration min_duration float64 max_velocity --- - --- std_msgs/Header header float64 position float64 velocity float64 error + ``` ---- - +---- ### `AssistedTeleop` ```yaml @@ -721,10 +324,10 @@ builtin_interfaces/Duration total_elapsed_time --- #feedback builtin_interfaces/Duration current_teleop_duration + ``` ---- - +---- ### `BackUp` ```yaml @@ -738,10 +341,10 @@ builtin_interfaces/Duration total_elapsed_time --- #feedback definition float32 distance_traveled + ``` ---- - +---- ### `ComputePathThroughPoses` ```yaml @@ -756,10 +359,10 @@ nav_msgs/Path path builtin_interfaces/Duration planning_time --- #feedback definition + ``` ---- - +---- ### `ComputePathToPose` ```yaml @@ -774,10 +377,10 @@ nav_msgs/Path path builtin_interfaces/Duration planning_time --- #feedback definition + ``` ---- - +---- ### `DriveOnHeading` ```yaml @@ -791,10 +394,10 @@ builtin_interfaces/Duration total_elapsed_time --- #feedback definition float32 distance_traveled + ``` ---- - +---- ### `DummyBehavior` ```yaml @@ -805,10 +408,10 @@ std_msgs/String command builtin_interfaces/Duration total_elapsed_time --- #feedback definition + ``` ---- - +---- ### `FollowPath` ```yaml @@ -823,10 +426,10 @@ std_msgs/Empty result #feedback definition float32 distance_to_goal float32 speed + ``` ---- - +---- ### `FollowWaypoints` ```yaml @@ -838,10 +441,10 @@ int32[] missed_waypoints --- #feedback definition uint32 current_waypoint + ``` ---- - +---- ### `NavigateThroughPoses` ```yaml @@ -859,10 +462,10 @@ builtin_interfaces/Duration estimated_time_remaining int16 number_of_recoveries float32 distance_remaining int16 number_of_poses_remaining + ``` ---- - +---- ### `NavigateToPose` ```yaml @@ -879,10 +482,10 @@ builtin_interfaces/Duration navigation_time builtin_interfaces/Duration estimated_time_remaining int16 number_of_recoveries float32 distance_remaining + ``` ---- - +---- ### `SmoothPath` ```yaml @@ -898,10 +501,10 @@ builtin_interfaces/Duration smoothing_duration bool was_completed --- #feedback definition + ``` ---- - +---- ### `Spin` ```yaml @@ -914,10 +517,10 @@ builtin_interfaces/Duration total_elapsed_time --- #feedback definition float32 angular_distance_traveled + ``` ---- - +---- ### `Wait` ```yaml @@ -929,6 +532,7 @@ builtin_interfaces/Duration total_elapsed_time --- #feedback definition builtin_interfaces/Duration time_left + ``` ---- +---- diff --git a/docs/developer_guide/image/workstation_architecture/workstation_by_supplier.png b/docs/developer_guide/image/workstation_architecture/workstation_by_supplier.png new file mode 100644 index 00000000..e5f3f666 Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_by_supplier.png differ diff --git a/docs/developer_guide/image/workstation_architecture/workstation_liquid_handler.png b/docs/developer_guide/image/workstation_architecture/workstation_liquid_handler.png new file mode 100644 index 00000000..71b2d9ad Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_liquid_handler.png differ diff --git a/docs/developer_guide/image/workstation_architecture/workstation_organic.png b/docs/developer_guide/image/workstation_architecture/workstation_organic.png new file mode 100644 index 00000000..cd159a81 Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_organic.png differ diff --git a/docs/developer_guide/image/workstation_architecture/workstation_organic_yed.png b/docs/developer_guide/image/workstation_architecture/workstation_organic_yed.png new file mode 100644 index 00000000..ab1da3fb Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_organic_yed.png differ diff --git a/docs/developer_guide/workstation_architecture.md b/docs/developer_guide/workstation_architecture.md index f9d113e2..073d9aea 100644 --- a/docs/developer_guide/workstation_architecture.md +++ b/docs/developer_guide/workstation_architecture.md @@ -1,378 +1,778 @@ -# 工作站基础架构设计文档 +# 工作站模板架构设计与对接指南 + +## 0. 问题简介 + +我们可以从以下几类例子,来理解对接大型工作站需要哪些设计。本文档之后的实战案例也将由这些组成。 + +### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发 + +![workstation_organic_yed](image/workstation_architecture/workstation_organic_yed.png) + +![workstation_organic](image/workstation_architecture/workstation_organic.png) + +这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合; + +1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如2个泵共用1个串口、所有设备共同接入PLC等。 +2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json并管理执行、同时更改物料信息 +3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息 + +### 0.2 移液工作站:物料系统和工作流模板管理 + +![workstation_liquid_handler](image/workstation_architecture/workstation_liquid_handler.png) + +1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供 +2. 所有任务系统均由工作站本身实现并下发指令,有统一的抽象函数可实现(pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。 +3. 物料系统为固定的板位系统:台面上有多个可摆放位置,摆放标准孔板。 + +### 0.3 厂家开发的定制大型工站 + +![workstation_by_supplier](image/workstation_architecture/workstation_by_supplier.png) + +由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信 + +1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取 +2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC连续读写来配置工作站可用的工作流; +3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接 ## 1. 整体架构图 -```mermaid +### 1.1 工作站核心架构 + +```{mermaid} graph TB - subgraph "工作站基础架构" - WB[WorkstationBase] - WB --> |继承| RPN[ROS2WorkstationNode] - WB --> |组合| WCB[WorkstationCommunicationBase] - WB --> |组合| MMB[MaterialManagementBase] - WB --> |组合| WHS[WorkstationHTTPService] + subgraph "工作站模板组成" + WB[WorkstationBase
工作流状态管理] + RPN[ROS2WorkstationNode
Protocol执行引擎] + WB -.post_init关联.-> RPN end - subgraph "通信层实现" - WCB --> |实现| PLC[PLCCommunication] - WCB --> |实现| SER[SerialCommunication] - WCB --> |实现| ETH[EthernetCommunication] + subgraph "物料管理系统" + DECK[Deck
PLR本地物料系统] + RS[ResourceSynchronizer
外部物料同步器] + WB --> DECK + WB --> RS + RS --> DECK end - subgraph "物料管理实现" - MMB --> |实现| PLR[PyLabRobotMaterialManager] - MMB --> |实现| BIO[BioyondMaterialManager] - MMB --> |实现| SIM[SimpleMaterialManager] + subgraph "通信与子设备管理" + HW[hardware_interface
硬件通信接口] + SUBDEV[子设备集合
pumps/grippers/sensors] + WB --> HW + RPN --> SUBDEV + HW -.代理模式.-> RPN end - subgraph "HTTP服务" - WHS --> |处理| LIMS[LIMS协议报送] - WHS --> |处理| MAT[物料变更报送] - WHS --> |处理| ERR[错误处理报送] + subgraph "工作流任务系统" + PROTO[Protocol定义
LiquidHandling/PlateHandling] + WORKFLOW[Workflow执行器
步骤管理与编排] + RPN --> PROTO + RPN --> WORKFLOW + WORKFLOW --> SUBDEV + end +``` + +### 1.2 外部系统对接关系 + +```{mermaid} +graph LR + subgraph "Uni-Lab-OS工作站" + WS[WorkstationBase + ROS2WorkstationNode] + DECK2[物料系统
Deck] + HW2[通信接口
hardware_interface] + HTTP[HTTP服务
WorkstationHTTPService] end - subgraph "具体工作站实现" - WB --> |继承| WS1[PLCWorkstation] - WB --> |继承| WS2[ReportingWorkstation] - WB --> |继承| WS3[HybridWorkstation] + subgraph "外部物料系统" + BIOYOND[Bioyond物料管理] + LIMS[LIMS系统] + WAREHOUSE[第三方仓储] end - subgraph "外部系统" - EXT1[PLC设备] --> |通信| PLC - EXT2[外部工作站] --> |HTTP报送| WHS - EXT3[LIMS系统] --> |HTTP报送| WHS - EXT4[Bioyond物料系统] --> |查询| BIO + subgraph "外部硬件系统" + PLC[PLC设备] + SERIAL[串口设备] + ROBOT[机械臂/机器人] end + + subgraph "云端系统" + CLOUD[UniLab云端
资源管理] + MONITOR[监控与调度] + end + + BIOYOND <-->|RPC双向同步| DECK2 + LIMS -->|HTTP报送| HTTP + WAREHOUSE <-->|API对接| DECK2 + + PLC <-->|Modbus TCP| HW2 + SERIAL <-->|串口通信| HW2 + ROBOT <-->|SDK/API| HW2 + + WS -->|ROS消息| CLOUD + CLOUD -->|任务下发| WS + MONITOR -->|状态查询| WS +``` + +### 1.3 具体实现示例 + +```{mermaid} +graph TB + subgraph "工作站基类" + BASE[WorkstationBase
抽象基类] + end + + subgraph "Bioyond集成工作站" + BW[BioyondWorkstation] + BW_DECK[Deck + Warehouses] + BW_SYNC[BioyondResourceSynchronizer] + BW_HW[BioyondV1RPC] + BW_HTTP[HTTP报送服务] + + BW --> BW_DECK + BW --> BW_SYNC + BW --> BW_HW + BW --> BW_HTTP + end + + subgraph "纯协议节点" + PN[ProtocolNode] + PN_SUB[子设备集合] + PN_PROTO[Protocol工作流] + + PN --> PN_SUB + PN --> PN_PROTO + end + + subgraph "PLC控制工作站" + PW[PLCWorkstation] + PW_DECK[Deck物料系统] + PW_PLC[Modbus PLC客户端] + PW_WF[工作流定义] + + PW --> PW_DECK + PW --> PW_PLC + PW --> PW_WF + end + + BASE -.继承.-> BW + BASE -.继承.-> PN + BASE -.继承.-> PW ``` ## 2. 类关系图 -```mermaid +```{mermaid} classDiagram class WorkstationBase { <> - +device_id: str - +communication: WorkstationCommunicationBase - +material_management: MaterialManagementBase - +http_service: WorkstationHTTPService - +workflow_status: WorkflowStatus - +supported_workflows: Dict - - +_create_communication_module()* - +_create_material_management_module()* - +_register_supported_workflows()* - - +process_step_finish_report() - +process_sample_finish_report() - +process_order_finish_report() - +process_material_change_report() - +handle_external_error() - - +start_workflow() - +stop_workflow() - +get_workflow_status() + +_ros_node: ROS2WorkstationNode + +deck: Deck + +plr_resources: Dict[str, PLRResource] + +resource_synchronizer: ResourceSynchronizer + +hardware_interface: Union[Any, str] + +current_workflow_status: WorkflowStatus + +supported_workflows: Dict[str, WorkflowInfo] + + +post_init(ros_node)* + +set_hardware_interface(interface) + +call_device_method(method, *args, **kwargs) +get_device_status() - } + +is_device_available() + +get_deck() + +get_all_resources() + +find_resource_by_name(name) + +find_resources_by_type(type) + +sync_with_external_system() + + +execute_workflow(name, params) + +stop_workflow(emergency) + +workflow_status + +is_busy + } + class ROS2WorkstationNode { - +sub_devices: Dict - +protocol_names: List - +execute_single_action() - +create_ros_action_server() - +initialize_device() - } - - class WorkstationCommunicationBase { - <> - +config: CommunicationConfig - +is_connected: bool - +connect() - +disconnect() - +start_workflow()* - +stop_workflow()* - +get_device_status()* - +write_register() - +read_register() - } - - class MaterialManagementBase { - <> +device_id: str - +deck_config: Dict + +children: Dict[str, Any] + +sub_devices: Dict + +protocol_names: List[str] + +_action_clients: Dict + +_action_servers: Dict +resource_tracker: DeviceNodeResourceTracker - +plr_deck: Deck - +find_materials_by_type() - +update_material_location() - +convert_to_unilab_format() - +_create_resource_by_type()* - } + +initialize_device(device_id, config) + +create_ros_action_server(action_name, mapping) + +execute_single_action(device_id, action, kwargs) + +update_resource(resources) + +transfer_resource_to_another(resources, target, sites) + +_setup_hardware_proxy(device, comm_device, read, write) + } + + %% 物料管理相关类 + class Deck { + +name: str + +children: List + +assign_child_resource() + } + + class ResourceSynchronizer { + <> + +workstation: WorkstationBase + +sync_from_external()* + +sync_to_external(plr_resource)* + +handle_external_change(change_info)* + } + + class BioyondResourceSynchronizer { + +bioyond_api_client: BioyondV1RPC + +sync_interval: int + +last_sync_time: float + + +initialize() + +sync_from_external() + +sync_to_external(resource) + +handle_external_change(change_info) + } + + %% 硬件接口相关类 + class HardwareInterface { + <> + } + + class BioyondV1RPC { + +base_url: str + +api_key: str + +stock_material() + +add_material() + +material_inbound() + } + + %% 服务类 class WorkstationHTTPService { - +workstation_instance: WorkstationBase + +workstation: WorkstationBase +host: str +port: int + +server: HTTPServer + +running: bool + +start() +stop() +_handle_step_finish_report() + +_handle_sample_finish_report() + +_handle_order_finish_report() +_handle_material_change_report() + +_handle_error_handling_report() } + + %% 具体实现类 + class BioyondWorkstation { + +bioyond_config: Dict + +workflow_mappings: Dict + +workflow_sequence: List - class PLCWorkstation { - +plc_config: Dict - +modbus_client: ModbusTCPClient - +_create_communication_module() - +_create_material_management_module() - +_register_supported_workflows() + +post_init(ros_node) + +transfer_resource_to_another() + +resource_tree_add(resources) + +append_to_workflow_sequence(name) + +get_all_workflows() + +get_bioyond_status() } - - class ReportingWorkstation { - +report_handlers: Dict - +_create_communication_module() - +_create_material_management_module() - +_register_supported_workflows() + + class ProtocolNode { + +post_init(ros_node) } - - WorkstationBase --|> ROS2WorkstationNode - WorkstationBase *-- WorkstationCommunicationBase - WorkstationBase *-- MaterialManagementBase - WorkstationBase *-- WorkstationHTTPService - - PLCWorkstation --|> WorkstationBase - ReportingWorkstation --|> WorkstationBase - - WorkstationCommunicationBase <|-- PLCCommunication - WorkstationCommunicationBase <|-- DummyCommunication - - MaterialManagementBase <|-- PyLabRobotMaterialManager - MaterialManagementBase <|-- SimpleMaterialManager + + %% 核心关系 + WorkstationBase o-- ROS2WorkstationNode : post_init关联 + WorkstationBase o-- WorkstationHTTPService : 可选服务 + + %% 物料管理侧 + WorkstationBase *-- Deck : deck + WorkstationBase *-- ResourceSynchronizer : 可选组合 + ResourceSynchronizer <|-- BioyondResourceSynchronizer + + %% 硬件接口侧 + WorkstationBase o-- HardwareInterface : hardware_interface + HardwareInterface <|.. BioyondV1RPC : 实现 + BioyondResourceSynchronizer --> BioyondV1RPC : 使用 + + %% 继承关系 + BioyondWorkstation --|> WorkstationBase + ProtocolNode --|> WorkstationBase + ROS2WorkstationNode --|> BaseROS2DeviceNode : 继承 ``` ## 3. 工作站启动时序图 -```mermaid +```{mermaid} sequenceDiagram participant APP as Application participant WS as WorkstationBase - participant COMM as CommunicationModule - participant MAT as MaterialManager - participant HTTP as HTTPService + participant DECK as PLR Deck + participant SYNC as ResourceSynchronizer + participant HW as HardwareInterface participant ROS as ROS2WorkstationNode - - APP->>WS: 创建工作站实例 - WS->>ROS: 初始化ROS2WorkstationNode - ROS->>ROS: 初始化子设备 + participant HTTP as HTTPService + + APP->>WS: 创建工作站实例(__init__) + WS->>DECK: 初始化PLR Deck + DECK->>DECK: 创建Warehouse等子资源 + DECK-->>WS: Deck创建完成 + + WS->>HW: 创建硬件接口(如BioyondV1RPC) + HW->>HW: 建立连接(PLC/RPC/串口等) + HW-->>WS: 硬件接口就绪 + + WS->>SYNC: 创建ResourceSynchronizer(可选) + SYNC->>HW: 使用hardware_interface + SYNC->>SYNC: 初始化同步配置 + SYNC-->>WS: 同步器创建完成 + + WS->>SYNC: sync_from_external() + SYNC->>HW: 查询外部物料系统 + HW-->>SYNC: 返回物料数据 + SYNC->>DECK: 转换并添加到Deck + SYNC-->>WS: 同步完成 + + Note over WS: __init__完成,等待ROS节点 + + APP->>ROS: 初始化ROS2WorkstationNode + ROS->>ROS: 初始化子设备(children) + ROS->>ROS: 创建Action客户端 ROS->>ROS: 设置硬件接口代理 - - WS->>COMM: _create_communication_module() - COMM->>COMM: 初始化通信配置 - COMM->>COMM: 建立PLC/串口连接 - COMM-->>WS: 返回通信模块实例 - - WS->>MAT: _create_material_management_module() - MAT->>MAT: 创建PyLabRobot Deck - MAT->>MAT: 初始化物料资源 - MAT->>MAT: 注册到ResourceTracker - MAT-->>WS: 返回物料管理实例 - - WS->>WS: _register_supported_workflows() - WS->>WS: _create_workstation_services() - WS->>HTTP: _start_http_service() - HTTP->>HTTP: 创建HTTP服务器 - HTTP->>HTTP: 启动监听线程 - HTTP-->>WS: HTTP服务启动完成 - - WS-->>APP: 工作站初始化完成 + ROS-->>APP: ROS节点就绪 + + APP->>WS: post_init(ros_node) + WS->>WS: self._ros_node = ros_node + WS->>ROS: update_resource([deck]) + ROS->>ROS: 上传物料到云端 + ROS-->>WS: 上传完成 + + WS->>HTTP: 创建WorkstationHTTPService(可选) + HTTP->>HTTP: 启动HTTP服务器线程 + HTTP-->>WS: HTTP服务启动 + + WS-->>APP: 工作站完全就绪 ``` -## 4. 工作流执行时序图 +## 4. 工作流执行时序图(Protocol模式) -```mermaid +```{mermaid} sequenceDiagram - participant EXT as ExternalSystem - participant WS as WorkstationBase - participant COMM as CommunicationModule - participant MAT as MaterialManager + participant CLIENT as 客户端 participant ROS as ROS2WorkstationNode - participant DEV as SubDevice + participant WS as WorkstationBase + participant HW as HardwareInterface + participant DECK as PLR Deck + participant CLOUD as 云端资源管理 + participant DEV as 子设备 + + CLIENT->>ROS: 发送Protocol Action请求 + ROS->>ROS: execute_protocol回调 + ROS->>ROS: 从Goal提取参数 + ROS->>ROS: 调用protocol_steps_generator + ROS->>ROS: 生成action步骤列表 + + ROS->>WS: 更新workflow_status = RUNNING + + loop 执行每个步骤 + alt 调用子设备 + ROS->>ROS: execute_single_action(device_id, action, params) + ROS->>DEV: 发送Action Goal(通过Action Client) + DEV->>DEV: 执行设备动作 + DEV-->>ROS: 返回Result + else 调用工作站自身 + ROS->>WS: call_device_method(method, *args) + alt 直接模式 + WS->>HW: 调用hardware_interface方法 + HW->>HW: 执行硬件操作 + HW-->>WS: 返回结果 + else 代理模式 + WS->>ROS: 转发到子设备 + ROS->>DEV: 调用子设备方法 + DEV-->>ROS: 返回结果 + ROS-->>WS: 返回结果 + end + WS-->>ROS: 返回结果 + end - EXT->>WS: start_workflow(type, params) - WS->>WS: 验证工作流类型 - WS->>COMM: start_workflow(type, params) - COMM->>COMM: 发送启动命令到PLC - COMM-->>WS: 启动成功 - - WS->>WS: 更新workflow_status = RUNNING - - loop 工作流步骤执行 - WS->>ROS: execute_single_action(device_id, action, params) - ROS->>DEV: 发送ROS Action请求 - DEV->>DEV: 执行设备动作 - DEV-->>ROS: 返回执行结果 - ROS-->>WS: 返回动作结果 - - WS->>MAT: update_material_location(material_id, location) - MAT->>MAT: 更新PyLabRobot资源状态 - MAT-->>WS: 更新完成 + ROS->>DECK: 更新本地物料状态 + DECK->>DECK: 修改PLR资源属性 end - - WS->>COMM: get_workflow_status() - COMM->>COMM: 查询PLC状态寄存器 - COMM-->>WS: 返回状态信息 - - WS->>WS: 更新workflow_status = COMPLETED - WS-->>EXT: 工作流执行完成 + + ROS->>CLOUD: 同步物料到云端(可选) + CLOUD-->>ROS: 同步完成 + + ROS->>WS: 更新workflow_status = COMPLETED + ROS-->>CLIENT: 返回Protocol Result ``` ## 5. HTTP报送处理时序图 -```mermaid +```{mermaid} sequenceDiagram - participant EXT as ExternalWorkstation + participant EXT as 外部工作站/LIMS participant HTTP as HTTPService participant WS as WorkstationBase - participant MAT as MaterialManager - participant DB as DataStorage - + participant DECK as PLR Deck + participant SYNC as ResourceSynchronizer + participant CLOUD as 云端 + EXT->>HTTP: POST /report/step_finish HTTP->>HTTP: 解析请求数据 HTTP->>HTTP: 验证LIMS协议字段 HTTP->>WS: process_step_finish_report(request) - - WS->>WS: 增加接收计数 + + WS->>WS: 增加接收计数(_reports_received_count++) WS->>WS: 记录步骤完成事件 - WS->>MAT: 更新相关物料状态 - MAT->>MAT: 更新PyLabRobot资源 - MAT-->>WS: 更新完成 - - WS->>DB: 保存报送记录 - DB-->>WS: 保存完成 - + WS->>DECK: 更新相关物料状态(可选) + DECK->>DECK: 修改PLR资源状态 + + WS->>WS: 保存报送记录到内存 + WS-->>HTTP: 返回处理结果 HTTP->>HTTP: 构造HTTP响应 HTTP-->>EXT: 200 OK + acknowledgment_id - - Note over EXT,DB: 类似处理sample_finish, order_finish, material_change等报送 + + Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送 + + alt 物料变更报送 + EXT->>HTTP: POST /report/material_change + HTTP->>WS: process_material_change_report(data) + WS->>DECK: 查找或创建物料 + WS->>SYNC: sync_to_external(resource) + SYNC->>SYNC: 同步到外部系统(如Bioyond) + SYNC-->>WS: 同步完成 + WS->>CLOUD: update_resource(通过ROS节点) + CLOUD-->>WS: 上传完成 + WS-->>HTTP: 返回结果 + HTTP-->>EXT: 200 OK + end ``` ## 6. 错误处理时序图 -```mermaid +```{mermaid} sequenceDiagram - participant DEV as Device + participant DEV as 子设备/外部系统 + participant ROS as ROS2WorkstationNode participant WS as WorkstationBase - participant COMM as CommunicationModule + participant HW as HardwareInterface participant HTTP as HTTPService - participant EXT as ExternalSystem - - DEV->>WS: 设备错误事件 - WS->>WS: handle_external_error(error_data) - WS->>WS: 记录错误历史 - - alt 关键错误 - WS->>COMM: emergency_stop() - COMM->>COMM: 发送紧急停止命令 - WS->>WS: 更新workflow_status = ERROR - else 普通错误 - WS->>WS: 标记动作失败 - WS->>WS: 触发重试逻辑 + participant LOG as 日志系统 + + alt 设备错误(ROS Action失败) + DEV->>ROS: Action返回失败结果 + ROS->>ROS: 记录错误信息 + ROS->>WS: 更新workflow_status = ERROR + ROS->>LOG: 记录错误日志 + else 外部系统错误报送 + DEV->>HTTP: POST /report/error_handling + HTTP->>WS: handle_external_error(error_data) + WS->>WS: 记录错误历史 + WS->>LOG: 记录错误日志 end - - WS->>HTTP: 记录错误报送 - HTTP->>EXT: 主动通知错误状态 - - WS-->>DEV: 错误处理完成 + + alt 关键错误需要停止 + WS->>ROS: stop_workflow(emergency=True) + ROS->>ROS: 取消所有进行中的Action + ROS->>HW: 调用emergency_stop()(如果支持) + HW->>HW: 执行紧急停止 + WS->>WS: 更新workflow_status = ERROR + else 可恢复错误 + WS->>WS: 标记步骤失败 + WS->>ROS: 触发重试逻辑(可选) + ROS->>DEV: 重新发送Action + end + + WS-->>HTTP: 返回错误处理结果 + HTTP-->>DEV: 200 OK + 处理状态 ``` ## 7. 典型工作站实现示例 -### 7.1 PLC工作站实现 +### 7.1 Bioyond集成工作站实现 + +```python +class BioyondWorkstation(WorkstationBase): + def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs): + # 初始化deck + super().__init__(deck=deck, *args, **kwargs) + + # 设置硬件接口为Bioyond RPC客户端 + self.hardware_interface = BioyondV1RPC(bioyond_config) + + # 创建资源同步器 + self.resource_synchronizer = BioyondResourceSynchronizer(self) + + # 从Bioyond同步物料到本地deck + self.resource_synchronizer.sync_from_external() + + # 配置工作流 + self.workflow_mappings = bioyond_config.get("workflow_mappings", {}) + + def post_init(self, ros_node: ROS2WorkstationNode): + """ROS节点就绪后的初始化""" + self._ros_node = ros_node + + # 上传deck(包括所有物料)到云端 + ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, + True, + resources=[self.deck] + ) + + def resource_tree_add(self, resources: List[ResourcePLR]): + """添加物料并同步到Bioyond""" + for resource in resources: + self.deck.assign_child_resource(resource, location) + self.resource_synchronizer.sync_to_external(resource) +``` + +### 7.2 纯协议节点实现 + +```python +class ProtocolNode(WorkstationBase): + """纯协议节点,不需要物料管理和外部通信""" + + def __init__(self, deck: Optional[Deck] = None, *args, **kwargs): + super().__init__(deck=deck, *args, **kwargs) + # 不设置hardware_interface和resource_synchronizer + # 所有功能通过子设备协同完成 + + def post_init(self, ros_node: ROS2WorkstationNode): + self._ros_node = ros_node + # 不需要上传物料或其他初始化 +``` + +### 7.3 PLC直接控制工作站 ```python class PLCWorkstation(WorkstationBase): - def _create_communication_module(self): - return PLCCommunication(self.communication_config) - - def _create_material_management_module(self): - return PyLabRobotMaterialManager( - self.device_id, - self.deck_config, - self.resource_tracker + def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs): + super().__init__(deck=deck, *args, **kwargs) + + # 设置硬件接口为Modbus客户端 + from pymodbus.client import ModbusTcpClient + self.hardware_interface = ModbusTcpClient( + host=plc_config["host"], + port=plc_config["port"] ) - - def _register_supported_workflows(self): + self.hardware_interface.connect() + + # 定义支持的工作流 self.supported_workflows = { - "battery_assembly": WorkflowInfo(...), - "quality_check": WorkflowInfo(...) - } -``` - -### 7.2 报送接收工作站实现 - -```python -class ReportingWorkstation(WorkstationBase): - def _create_communication_module(self): - return DummyCommunication(self.communication_config) - - def _create_material_management_module(self): - return SimpleMaterialManager( - self.device_id, - self.deck_config, - self.resource_tracker - ) - - def _register_supported_workflows(self): - self.supported_workflows = { - "data_collection": WorkflowInfo(...), - "report_processing": WorkflowInfo(...) + "battery_assembly": WorkflowInfo( + name="电池组装", + description="自动化电池组装流程", + estimated_duration=300.0, + required_materials=["battery_cell", "connector"], + output_product="battery_pack", + parameters_schema={"quantity": int, "model": str} + ) } + + def execute_workflow(self, workflow_name: str, parameters: Dict): + """通过PLC执行工作流""" + workflow_id = self._get_workflow_id(workflow_name) + + # 写入PLC寄存器启动工作流 + self.hardware_interface.write_register(100, workflow_id) + self.hardware_interface.write_register(101, parameters["quantity"]) + + self.current_workflow_status = WorkflowStatus.RUNNING + return True ``` ## 8. 核心接口说明 -### 8.1 必须实现的抽象方法 -- `_create_communication_module()`: 创建通信模块 -- `_create_material_management_module()`: 创建物料管理模块 -- `_register_supported_workflows()`: 注册支持的工作流 +### 8.1 WorkstationBase核心属性 + +| 属性 | 类型 | 说明 | +| --------------------------- | ----------------------- | ----------------------------- | +| `_ros_node` | ROS2WorkstationNode | ROS节点引用,由post_init设置 | +| `deck` | Deck | PyLabRobot Deck,本地物料系统 | +| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 | +| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) | +| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 | +| `current_workflow_status` | WorkflowStatus | 当前工作流状态 | +| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 | + +### 8.2 必须实现的方法 + +- `post_init(ros_node)`: ROS节点就绪后的初始化,必须实现 + +### 8.3 硬件接口相关方法 + +- `set_hardware_interface(interface)`: 设置硬件接口 +- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用 + - 支持直接模式: 直接调用hardware_interface的方法 + - 支持代理模式: hardware_interface="proxy:device_id"通过ROS转发 +- `get_device_status()`: 获取设备状态 +- `is_device_available()`: 检查设备可用性 + +### 8.4 物料管理方法 + +- `get_deck()`: 获取PLR Deck +- `get_all_resources()`: 获取所有物料 +- `find_resource_by_name(name)`: 按名称查找物料 +- `find_resources_by_type(type)`: 按类型查找物料 +- `sync_with_external_system()`: 触发外部同步 + +### 8.5 工作流控制方法 + +- `execute_workflow(name, params)`: 执行工作流 +- `stop_workflow(emergency)`: 停止工作流 +- `workflow_status`: 获取工作流状态(属性) +- `is_busy`: 检查是否忙碌(属性) +- `workflow_runtime`: 获取运行时间(属性) + +### 8.6 可选的HTTP报送处理方法 -### 8.2 可重写的报送处理方法 - `process_step_finish_report()`: 步骤完成处理 - `process_sample_finish_report()`: 样本完成处理 - `process_order_finish_report()`: 订单完成处理 - `process_material_change_report()`: 物料变更处理 - `handle_external_error()`: 错误处理 -### 8.3 工作流控制接口 -- `start_workflow()`: 启动工作流 -- `stop_workflow()`: 停止工作流 -- `get_workflow_status()`: 获取状态 +### 8.7 ROS2WorkstationNode核心方法 + +- `initialize_device(device_id, config)`: 初始化子设备 +- `create_ros_action_server(action_name, mapping)`: 创建Action服务器 +- `execute_single_action(device_id, action, kwargs)`: 执行单个动作 +- `update_resource(resources)`: 同步物料到云端 +- `transfer_resource_to_another(...)`: 跨设备物料转移 ## 9. 配置参数说明 +### 9.1 工作站初始化配置 + ```python -workstation_config = { - "communication_config": { - "protocol": "modbus_tcp", - "host": "192.168.1.100", - "port": 502 +# 示例1: Bioyond集成工作站 +bioyond_config = { + "base_url": "http://192.168.1.100:8080", + "api_key": "your_api_key", + "sync_interval": 600, # 同步间隔(秒) + "workflow_mappings": { + "样品制备": "workflow_uuid_1", + "质检流程": "workflow_uuid_2" }, - "deck_config": { - "size_x": 1000.0, - "size_y": 1000.0, - "size_z": 500.0 + "material_type_mappings": { + "plate": "板", + "tube": "试管" }, - "http_service_config": { - "enabled": True, - "host": "127.0.0.1", - "port": 8081 - }, - "communication_interfaces": { - "logical_device_1": CommunicationInterface(...) + "warehouse_mapping": { + "冷藏区": { + "uuid": "warehouse_uuid_1", + "locations": {...} + } + } +} + +# 创建Deck +from pylabrobot.resources import Deck +deck = Deck(name="main_deck", size_x=1000, size_y=800, size_z=200) + +workstation = BioyondWorkstation( + bioyond_config=bioyond_config, + deck=deck +) +``` + +### 9.2 子设备配置(children) + +```python +# 在devices.json中配置 +{ + "bioyond_workstation": { + "type": "protocol", # 表示这是工作站节点 + "protocol_type": ["LiquidHandling", "PlateHandling"], + "children": { + "pump_1": { + "type": "device", + "driver": "TricontInnovaDriver", + "communication": "serial_1", + "config": {...} + }, + "gripper_1": { + "type": "device", + "driver": "RobotiqGripperDriver", + "communication": "io_modbus_1", + "config": {...} + }, + "serial_1": { + "type": "communication", + "protocol": "serial", + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "io_modbus_1": { + "type": "communication", + "protocol": "modbus_tcp", + "host": "192.168.1.101", + "port": 502 + } + } } } ``` -这个架构设计支持: -1. **灵活的通信方式**: 通过CommunicationBase支持PLC、串口、以太网等 -2. **多样的物料管理**: 支持PyLabRobot、Bioyond、简单物料系统 -3. **统一的HTTP报送**: 基于LIMS协议的标准化报送接口 -4. **完整的工作流控制**: 支持动态和静态工作流 -5. **强大的错误处理**: 多层次的错误处理和恢复机制 +### 9.3 HTTP服务配置 + +```python +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService + +# 创建HTTP服务(可选) +http_service = WorkstationHTTPService( + workstation_instance=workstation, + host="0.0.0.0", # 监听所有网卡 + port=8081 +) +http_service.start() +``` + +## 10. 架构设计特点总结 + +这个简化后的架构设计具有以下特点: + +### 10.1 清晰的职责分离 + +- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理 +- **ROS2WorkstationNode**: 负责子设备管理、Protocol执行、云端物料同步 +- **ResourceSynchronizer**: 可选的外部物料系统同步(如Bioyond) +- **WorkstationHTTPService**: 可选的HTTP报送接收服务 + +### 10.2 灵活的硬件接口模式 + +1. **直接模式**: hardware_interface是具体对象(如BioyondV1RPC、ModbusClient) +2. **代理模式**: hardware_interface="proxy:device_id",通过ROS节点转发到子设备 +3. **混合模式**: 工作站有自己的接口,同时管理多个子设备 + +### 10.3 统一的物料系统 + +- 基于PyLabRobot Deck的标准化物料表示 +- 通过ResourceSynchronizer实现与外部系统(如Bioyond、LIMS)的双向同步 +- 通过ROS2WorkstationNode实现与云端的物料状态同步 + +### 10.4 Protocol驱动的工作流 + +- ROS2WorkstationNode负责Protocol的执行和步骤管理 +- 支持子设备协同(通过Action Client调用) +- 支持工作站直接控制(通过hardware_interface) + +### 10.5 可选的HTTP报送服务 + +- 基于LIMS协议规范的统一报送接口 +- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型 +- 与工作站解耦,可独立启停 + +### 10.6 简化的初始化流程 + +``` +1. __init__: 创建deck、设置hardware_interface、创建resource_synchronizer +2. 从外部系统同步物料(如果有) +3. ROS节点初始化子设备 +4. post_init: 关联ROS节点、上传物料到云端 +5. (可选)启动HTTP服务 +``` + +这种设计既保持了灵活性,又避免了过度抽象,更适合实际的工作站对接场景。 diff --git a/docs/intro.md b/docs/intro.md index 163598b4..3b176daf 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -32,9 +32,8 @@ developer_guide/device_driver developer_guide/add_device developer_guide/add_action developer_guide/actions +developer_guide/workstation_architecture developer_guide/add_protocol -developer_guide/add_batteryPLC -developer_guide/materials_tutorial.md ``` ## 接口文档 diff --git a/docs/requirements.txt b/docs/requirements.txt index 36809637..1cc92477 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,6 +2,7 @@ sphinx>=7.0.0 sphinx-rtd-theme>=2.0.0 myst-parser>=2.0.0 +sphinxcontrib-mermaid # 用于支持Jupyter notebook文档 myst-nb>=1.0.0 diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 013855ed..20b6ef0b 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -24,13 +24,42 @@ "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" }, "material_type_mappings": { - "烧杯": ["BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"], - "试剂瓶": ["BIOYOND_PolymerStation_1BottleCarrier", ""], - "样品板": ["BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"], - "分装板": ["BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"], - "样品瓶": ["BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"], - "90%分装小瓶": ["BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"], - "10%分装小瓶": ["BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"] + "烧杯": [ + "BIOYOND_PolymerStation_1FlaskCarrier", + "3a14196b-24f2-ca49-9081-0cab8021bf1a" + ], + "试剂瓶": [ + "BIOYOND_PolymerStation_1BottleCarrier", + "" + ], + "样品板": [ + "BIOYOND_PolymerStation_6StockCarrier", + "3a14196e-b7a0-a5da-1931-35f3000281e9" + ], + "分装板": [ + "BIOYOND_PolymerStation_6VialCarrier", + "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + ], + "样品瓶": [ + "BIOYOND_PolymerStation_Solid_Stock", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ], + "90%分装小瓶": [ + "BIOYOND_PolymerStation_Solid_Vial", + "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + ], + "10%分装小瓶": [ + "BIOYOND_PolymerStation_Liquid_Vial", + "3a14196c-76be-2279-4e22-7310d69aed68" + ], + "枪头盒": [ + "BIOYOND_PolymerStation_TipBox", + "" + ], + "反应器": [ + "BIOYOND_PolymerStation_Reactor", + "" + ] } }, "deck": { @@ -46,8 +75,7 @@ { "id": "Bioyond_Deck", "name": "Bioyond_Deck", - "children": [ - ], + "children": [], "parent": "reaction_station_bioyond", "type": "deck", "class": "BIOYOND_PolymerReactionStation_Deck", @@ -69,4 +97,4 @@ "data": {} } ] -} +} \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index 45d0cadb..78a00eb4 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -233,7 +233,7 @@ class BioyondV1RPC(BaseRequest): return response.get("data", {}) def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict: - """指定库位出库物料""" + """指定库位出库物料(通过库位名称)""" location_id = LOCATION_MAPPING.get(location_name, location_name) params = { @@ -251,7 +251,36 @@ class BioyondV1RPC(BaseRequest): }) if not response or response['code'] != 1: - return {} + return None + return response + + def material_outbound_by_id(self, material_id: str, location_id: str, quantity: int) -> dict: + """指定库位出库物料(直接使用location_id) + + Args: + material_id: 物料ID + location_id: 库位ID(不是库位名称,是UUID) + quantity: 数量 + + Returns: + dict: API响应,失败返回None + """ + params = { + "materialId": material_id, + "locationId": location_id, + "quantity": quantity + } + + response = self.post( + url=f'{self.host}/api/lims/storage/outbound', + params={ + "apiKey": self.api_key, + "requestTime": self.get_current_time_iso8601(), + "data": params + }) + + if not response or response['code'] != 1: + return None return response # ==================== 工作流查询相关接口 ==================== @@ -703,10 +732,10 @@ class BioyondV1RPC(BaseRequest): """预加载材料列表到缓存中""" try: print("正在加载材料列表缓存...") - + # 加载所有类型的材料:耗材(0)、样品(1)、试剂(2) material_types = [1, 2] - + for type_mode in material_types: print(f"正在加载类型 {type_mode} 的材料...") stock_query = f'{{"typeMode": {type_mode}, "includeDetail": true}}' @@ -723,7 +752,7 @@ class BioyondV1RPC(BaseRequest): material_id = material.get("id") if material_name and material_id: self.material_cache[material_name] = material_id - + # 处理样品板等容器中的detail材料 detail_materials = material.get("detail", []) for detail_material in detail_materials: diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index b56965a6..8617a13f 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -7,7 +7,7 @@ from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstati class BioyondDispensingStation(BioyondWorkstation): def __init__( - self, + self, config, # 桌子 deck, @@ -77,7 +77,7 @@ class BioyondDispensingStation(BioyondWorkstation): - hold_m_name: 库位名称,如"C01",用于查找对应的holdMId 返回: 任务创建结果 - + 异常: - BioyondException: 各种错误情况下的统一异常 """ @@ -85,7 +85,7 @@ class BioyondDispensingStation(BioyondWorkstation): # 1. 参数验证 if not hold_m_name: raise BioyondException("hold_m_name 是必填参数") - + # 检查90%物料参数的完整性 # 90%_1物料:如果有物料名称或目标重量,就必须有全部参数 if percent_90_1_assign_material_name or percent_90_1_target_weigh: @@ -93,21 +93,21 @@ class BioyondDispensingStation(BioyondWorkstation): raise BioyondException("90%_1物料:如果提供了目标重量,必须同时提供物料名称") if not percent_90_1_target_weigh: raise BioyondException("90%_1物料:如果提供了物料名称,必须同时提供目标重量") - + # 90%_2物料:如果有物料名称或目标重量,就必须有全部参数 if percent_90_2_assign_material_name or percent_90_2_target_weigh: if not percent_90_2_assign_material_name: raise BioyondException("90%_2物料:如果提供了目标重量,必须同时提供物料名称") if not percent_90_2_target_weigh: raise BioyondException("90%_2物料:如果提供了物料名称,必须同时提供目标重量") - + # 90%_3物料:如果有物料名称或目标重量,就必须有全部参数 if percent_90_3_assign_material_name or percent_90_3_target_weigh: if not percent_90_3_assign_material_name: raise BioyondException("90%_3物料:如果提供了目标重量,必须同时提供物料名称") if not percent_90_3_target_weigh: raise BioyondException("90%_3物料:如果提供了物料名称,必须同时提供目标重量") - + # 检查10%物料参数的完整性 # 10%_1物料:如果有物料名称、目标重量、体积或液体物料名称中的任何一个,就必须有全部参数 if any([percent_10_1_assign_material_name, percent_10_1_target_weigh, percent_10_1_volume, percent_10_1_liquid_material_name]): @@ -119,7 +119,7 @@ class BioyondDispensingStation(BioyondWorkstation): raise BioyondException("10%_1物料:如果提供了其他参数,必须同时提供液体体积") if not percent_10_1_liquid_material_name: raise BioyondException("10%_1物料:如果提供了其他参数,必须同时提供液体物料名称") - + # 10%_2物料:如果有物料名称、目标重量、体积或液体物料名称中的任何一个,就必须有全部参数 if any([percent_10_2_assign_material_name, percent_10_2_target_weigh, percent_10_2_volume, percent_10_2_liquid_material_name]): if not percent_10_2_assign_material_name: @@ -130,7 +130,7 @@ class BioyondDispensingStation(BioyondWorkstation): raise BioyondException("10%_2物料:如果提供了其他参数,必须同时提供液体体积") if not percent_10_2_liquid_material_name: raise BioyondException("10%_2物料:如果提供了其他参数,必须同时提供液体物料名称") - + # 10%_3物料:如果有物料名称、目标重量、体积或液体物料名称中的任何一个,就必须有全部参数 if any([percent_10_3_assign_material_name, percent_10_3_target_weigh, percent_10_3_volume, percent_10_3_liquid_material_name]): if not percent_10_3_assign_material_name: @@ -141,7 +141,7 @@ class BioyondDispensingStation(BioyondWorkstation): raise BioyondException("10%_3物料:如果提供了其他参数,必须同时提供液体体积") if not percent_10_3_liquid_material_name: raise BioyondException("10%_3物料:如果提供了其他参数,必须同时提供液体物料名称") - + # 2. 生成任务编码和设置默认值 order_code = "task_vial_" + str(int(datetime.now().timestamp())) if order_name is None: @@ -152,7 +152,7 @@ class BioyondDispensingStation(BioyondWorkstation): temperature = "40" if delay_time is None: delay_time = "600" - + # 3. 工作流ID workflow_id = "3a19310d-16b9-9d81-b109-0748e953694b" @@ -160,22 +160,22 @@ class BioyondDispensingStation(BioyondWorkstation): material_info = self.hardware_interface.material_id_query(workflow_id) if not material_info: raise BioyondException(f"无法查询工作流 {workflow_id} 的物料信息") - + # 获取locations列表 locations = material_info.get("locations", []) if isinstance(material_info, dict) else [] if not locations: raise BioyondException(f"工作流 {workflow_id} 没有找到库位信息") - + # 查找指定名称的库位 hold_mid = None for location in locations: if location.get("holdMName") == hold_m_name: hold_mid = location.get("holdMId") break - + if not hold_mid: raise BioyondException(f"未找到库位名称为 {hold_m_name} 的库位,请检查名称是否正确") - + extend_properties = f"{{\"{ hold_mid }\": {{}}}}" self.hardware_interface._logger.info(f"找到库位 {hold_m_name} 对应的holdMId: {hold_mid}") @@ -271,7 +271,7 @@ class BioyondDispensingStation(BioyondWorkstation): result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}") return json.dumps({"suc": True}) - + except BioyondException: # 重新抛出BioyondException raise @@ -307,7 +307,7 @@ class BioyondDispensingStation(BioyondWorkstation): - hold_m_name: 库位名称,如"ODA-1",用于查找对应的holdMId 返回: 任务创建结果 - + 异常: - BioyondException: 各种错误情况下的统一异常 """ @@ -321,8 +321,8 @@ class BioyondDispensingStation(BioyondWorkstation): raise BioyondException("volume 是必填参数") if not hold_m_name: raise BioyondException("hold_m_name 是必填参数") - - + + # 2. 生成任务编码和设置默认值 order_code = "task_oda_" + str(int(datetime.now().timestamp())) if order_name is None: @@ -333,30 +333,30 @@ class BioyondDispensingStation(BioyondWorkstation): temperature = "20" if delay_time is None: delay_time = "600" - + # 3. 工作流ID - 二胺溶液配置工作流 workflow_id = "3a15d4a1-3bbe-76f9-a458-292896a338f5" - + # 4. 查询工作流对应的holdMID material_info = self.hardware_interface.material_id_query(workflow_id) if not material_info: raise BioyondException(f"无法查询工作流 {workflow_id} 的物料信息") - + # 获取locations列表 locations = material_info.get("locations", []) if isinstance(material_info, dict) else [] if not locations: raise BioyondException(f"工作流 {workflow_id} 没有找到库位信息") - + # 查找指定名称的库位 hold_mid = None for location in locations: if location.get("holdMName") == hold_m_name: hold_mid = location.get("holdMId") break - + if not hold_mid: raise BioyondException(f"未找到库位名称为 {hold_m_name} 的库位,请检查名称是否正确") - + extend_properties = f"{{\"{ hold_mid }\": {{}}}}" self.hardware_interface._logger.info(f"找到库位 {hold_m_name} 对应的holdMId: {hold_mid}") @@ -397,9 +397,9 @@ class BioyondDispensingStation(BioyondWorkstation): # 7. 调用create_order方法创建任务 result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}") - + return json.dumps({"suc": True}) - + except BioyondException: # 重新抛出BioyondException raise @@ -409,17 +409,278 @@ class BioyondDispensingStation(BioyondWorkstation): self.hardware_interface._logger.error(error_msg) raise BioyondException(error_msg) + # 批量创建二胺溶液配置任务 + def batch_create_diamine_solution_tasks(self, + solutions, + liquid_material_name: str = "NMP", + speed: str = None, + temperature: str = None, + delay_time: str = None) -> str: + """ + 批量创建二胺溶液配置任务 + + 参数说明: + - solutions: 溶液列表(数组)或JSON字符串,格式如下: + [ + { + "name": "MDA", + "order": 0, + "solid_mass": 5.0, + "solvent_volume": 20, + ... + }, + ... + ] + - liquid_material_name: 液体物料名称,默认为"NMP" + - speed: 搅拌速度,如果为None则使用默认值400 + - temperature: 温度,如果为None则使用默认值20 + - delay_time: 延迟时间,如果为None则使用默认值600 + + 返回: JSON字符串格式的任务创建结果 + + 异常: + - BioyondException: 各种错误情况下的统一异常 + """ + try: + # 参数类型转换:如果是字符串则解析为列表 + if isinstance(solutions, str): + try: + solutions = json.loads(solutions) + except json.JSONDecodeError as e: + raise BioyondException(f"solutions JSON解析失败: {str(e)}") + + # 参数验证 + if not isinstance(solutions, list): + raise BioyondException("solutions 必须是列表类型或有效的JSON数组字符串") + + if not solutions: + raise BioyondException("solutions 列表不能为空") + + # 批量创建任务 + results = [] + success_count = 0 + failed_count = 0 + + for idx, solution in enumerate(solutions): + try: + # 提取参数 + name = solution.get("name") + solid_mass = solution.get("solid_mass") + solvent_volume = solution.get("solvent_volume") + order = solution.get("order") + + if not all([name, solid_mass is not None, solvent_volume is not None]): + self.hardware_interface._logger.warning( + f"跳过第 {idx + 1} 个溶液:缺少必要参数" + ) + results.append({ + "index": idx + 1, + "name": name, + "success": False, + "error": "缺少必要参数" + }) + failed_count += 1 + continue + + # 生成库位名称(直接使用物料名称) + # 如果需要其他命名规则,可以在这里调整 + hold_m_name = name + + # 调用单个任务创建方法 + result = self.create_diamine_solution_task( + order_name=f"二胺溶液配置-{name}", + material_name=name, + target_weigh=str(solid_mass), + volume=str(solvent_volume), + liquid_material_name=liquid_material_name, + speed=speed, + temperature=temperature, + delay_time=delay_time, + hold_m_name=hold_m_name + ) + + results.append({ + "index": idx + 1, + "name": name, + "success": True, + "hold_m_name": hold_m_name + }) + success_count += 1 + self.hardware_interface._logger.info( + f"成功创建二胺溶液配置任务: {name}" + ) + + except BioyondException as e: + results.append({ + "index": idx + 1, + "name": solution.get("name", "unknown"), + "success": False, + "error": str(e) + }) + failed_count += 1 + self.hardware_interface._logger.error( + f"创建第 {idx + 1} 个任务失败: {str(e)}" + ) + except Exception as e: + results.append({ + "index": idx + 1, + "name": solution.get("name", "unknown"), + "success": False, + "error": f"未知错误: {str(e)}" + }) + failed_count += 1 + self.hardware_interface._logger.error( + f"创建第 {idx + 1} 个任务时发生未知错误: {str(e)}" + ) + + # 返回汇总结果 + summary = { + "total": len(solutions), + "success": success_count, + "failed": failed_count, + "details": results + } + + self.hardware_interface._logger.info( + f"批量创建二胺溶液配置任务完成: 总数={len(solutions)}, " + f"成功={success_count}, 失败={failed_count}" + ) + + # 返回JSON字符串格式 + return json.dumps(summary, ensure_ascii=False) + + except BioyondException: + raise + except Exception as e: + error_msg = f"批量创建二胺溶液配置任务时发生未预期的错误: {str(e)}" + self.hardware_interface._logger.error(error_msg) + raise BioyondException(error_msg) + + # 批量创建90%10%小瓶投料任务 + def batch_create_90_10_vial_feeding_tasks(self, + titration, + hold_m_name: str = None, + speed: str = None, + temperature: str = None, + delay_time: str = None, + liquid_material_name: str = "NMP") -> str: + """ + 批量创建90%10%小瓶投料任务(仅创建1个任务,但包含所有90%和10%物料) + + 参数说明: + - titration: 滴定信息的字典或JSON字符串,格式如下: + { + "name": "BTDA", + "main_portion": 1.9152351915461294, # 主称固体质量(g) -> 90%物料 + "titration_portion": 0.05923407808905555, # 滴定固体质量(g) -> 10%物料固体 + "titration_solvent": 3.050555021586361 # 滴定溶液体积(mL) -> 10%物料液体 + } + - hold_m_name: 库位名称,如"C01"。必填参数 + - speed: 搅拌速度,如果为None则使用默认值400 + - temperature: 温度,如果为None则使用默认值40 + - delay_time: 延迟时间,如果为None则使用默认值600 + - liquid_material_name: 10%物料的液体物料名称,默认为"NMP" + + 返回: JSON字符串格式的任务创建结果 + + 异常: + - BioyondException: 各种错误情况下的统一异常 + """ + try: + # 参数类型转换:如果是字符串则解析为字典 + if isinstance(titration, str): + try: + titration = json.loads(titration) + except json.JSONDecodeError as e: + raise BioyondException(f"titration参数JSON解析失败: {str(e)}") + + # 参数验证 + if not isinstance(titration, dict): + raise BioyondException("titration 必须是字典类型或有效的JSON字符串") + + if not hold_m_name: + raise BioyondException("hold_m_name 是必填参数") + + if not titration: + raise BioyondException("titration 参数不能为空") + + # 提取滴定数据 + name = titration.get("name") + main_portion = titration.get("main_portion") # 主称固体质量 + titration_portion = titration.get("titration_portion") # 滴定固体质量 + titration_solvent = titration.get("titration_solvent") # 滴定溶液体积 + + if not all([name, main_portion is not None, titration_portion is not None, titration_solvent is not None]): + raise BioyondException("titration 数据缺少必要参数") + + # 将main_portion平均分成3份作为90%物料(3个小瓶) + portion_90 = main_portion / 3 + + # 调用单个任务创建方法 + result = self.create_90_10_vial_feeding_task( + order_name=f"90%10%小瓶投料-{name}", + speed=speed, + temperature=temperature, + delay_time=delay_time, + # 90%物料 - 主称固体平均分成3份 + percent_90_1_assign_material_name=name, + percent_90_1_target_weigh=str(round(portion_90, 6)), + percent_90_2_assign_material_name=name, + percent_90_2_target_weigh=str(round(portion_90, 6)), + percent_90_3_assign_material_name=name, + percent_90_3_target_weigh=str(round(portion_90, 6)), + # 10%物料 - 滴定固体 + 滴定溶剂(只使用第1个10%小瓶) + percent_10_1_assign_material_name=name, + percent_10_1_target_weigh=str(round(titration_portion, 6)), + percent_10_1_volume=str(round(titration_solvent, 6)), + percent_10_1_liquid_material_name=liquid_material_name, + hold_m_name=hold_m_name + ) + + summary = { + "success": True, + "hold_m_name": hold_m_name, + "material_name": name, + "90_vials": { + "count": 3, + "weight_per_vial": round(portion_90, 6), + "total_weight": round(main_portion, 6) + }, + "10_vials": { + "count": 1, + "solid_weight": round(titration_portion, 6), + "liquid_volume": round(titration_solvent, 6) + } + } + + self.hardware_interface._logger.info( + f"成功创建90%10%小瓶投料任务: {hold_m_name}, " + f"90%物料={portion_90:.6f}g×3, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL" + ) + + # 返回JSON字符串格式 + return json.dumps(summary, ensure_ascii=False) + + except BioyondException: + raise + except Exception as e: + error_msg = f"批量创建90%10%小瓶投料任务时发生未预期的错误: {str(e)}" + self.hardware_interface._logger.error(error_msg) + raise BioyondException(error_msg) + if __name__ == "__main__": bioyond = BioyondDispensingStation(config={ "api_key": "DE9BDDA0", "api_host": "http://192.168.1.200:44388" }) - + + # ============ 原有示例代码 ============ + # 示例1:使用material_id_query查询工作流对应的holdMID workflow_id_1 = "3a15d4a1-3bbe-76f9-a458-292896a338f5" # 二胺溶液配置工作流ID workflow_id_2 = "3a19310d-16b9-9d81-b109-0748e953694b" # 90%10%小瓶投料工作流ID - + #示例2:创建二胺溶液配置任务 - ODA,指定库位名称 # bioyond.create_diamine_solution_task( # order_code="task_oda_" + str(int(datetime.now().timestamp())), @@ -433,7 +694,7 @@ if __name__ == "__main__": # delay_time="600", # hold_m_name="烧杯ODA" # ) - + # bioyond.create_diamine_solution_task( # order_code="task_pda_" + str(int(datetime.now().timestamp())), # order_name="二胺溶液配置-PDA", @@ -446,7 +707,7 @@ if __name__ == "__main__": # delay_time="600", # hold_m_name="烧杯PDA-2" # ) - + # bioyond.create_diamine_solution_task( # order_code="task_mpda_" + str(int(datetime.now().timestamp())), # order_name="二胺溶液配置-MPDA", @@ -462,8 +723,8 @@ if __name__ == "__main__": bioyond.material_id_query("3a19310d-16b9-9d81-b109-0748e953694b") bioyond.material_id_query("3a15d4a1-3bbe-76f9-a458-292896a338f5") - - + + #示例4:创建90%10%小瓶投料任务 # vial_result = bioyond.create_90_10_vial_feeding_task( # order_code="task_vial_" + str(int(datetime.now().timestamp())), @@ -487,7 +748,7 @@ if __name__ == "__main__": # delay_time="1200", # hold_m_name="8.4分装板-1" # ) - + # vial_result = bioyond.create_90_10_vial_feeding_task( # order_code="task_vial_" + str(int(datetime.now().timestamp())), # order_name="90%10%小瓶投料-2", @@ -510,7 +771,7 @@ if __name__ == "__main__": # delay_time="1200", # hold_m_name="8.4分装板-2" # ) - + #启动调度器 #bioyond.scheduler_start() @@ -529,7 +790,7 @@ if __name__ == "__main__": material_data_yp = { "typeId": "3a14196e-b7a0-a5da-1931-35f3000281e9", #"code": "物料编码001", - #"barCode": "物料条码001", + #"barCode": "物料条码001", "name": "8.4样品板", "unit": "个", "quantity": 1, @@ -540,7 +801,7 @@ if __name__ == "__main__": "name": "BTDA-1", "quantity": 20, "x": 1, - "y": 1, + "y": 1, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -585,7 +846,7 @@ if __name__ == "__main__": material_data_yp = { "typeId": "3a14196e-b7a0-a5da-1931-35f3000281e9", #"code": "物料编码001", - #"barCode": "物料条码001", + #"barCode": "物料条码001", "name": "8.7样品板", "unit": "个", "quantity": 1, @@ -596,7 +857,7 @@ if __name__ == "__main__": "name": "mianfen", "quantity": 13, "x": 1, - "y": 1, + "y": 1, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -620,7 +881,7 @@ if __name__ == "__main__": material_data_fzb_1 = { "typeId": "3a14196e-5dfe-6e21-0c79-fe2036d052c4", #"code": "物料编码001", - #"barCode": "物料条码001", + #"barCode": "物料条码001", "name": "8.7分装板", "unit": "个", "quantity": 1, @@ -631,7 +892,7 @@ if __name__ == "__main__": "name": "10%小瓶1", "quantity": 1, "x": 1, - "y": 1, + "y": 1, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -642,7 +903,7 @@ if __name__ == "__main__": "name": "10%小瓶2", "quantity": 1, "x": 1, - "y": 2, + "y": 2, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -653,7 +914,7 @@ if __name__ == "__main__": "name": "10%小瓶3", "quantity": 1, "x": 1, - "y": 3, + "y": 3, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -697,7 +958,7 @@ if __name__ == "__main__": material_data_fzb_2 = { "typeId": "3a14196e-5dfe-6e21-0c79-fe2036d052c4", #"code": "物料编码001", - #"barCode": "物料条码001", + #"barCode": "物料条码001", "name": "8.4分装板-2", "unit": "个", "quantity": 1, @@ -708,7 +969,7 @@ if __name__ == "__main__": "name": "10%小瓶1", "quantity": 1, "x": 1, - "y": 1, + "y": 1, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -719,7 +980,7 @@ if __name__ == "__main__": "name": "10%小瓶2", "quantity": 1, "x": 1, - "y": 2, + "y": 2, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -730,7 +991,7 @@ if __name__ == "__main__": "name": "10%小瓶3", "quantity": 1, "x": 1, - "y": 3, + "y": 3, #"unit": "单位" "molecular": 1, "Parameters":"{\"molecular\": 1}" @@ -775,7 +1036,7 @@ if __name__ == "__main__": material_data_sb_oda = { "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", #"code": "物料编码001", - #"barCode": "物料条码001", + #"barCode": "物料条码001", "name": "mianfen1", "unit": "个", "quantity": 1, @@ -785,7 +1046,7 @@ if __name__ == "__main__": material_data_sb_pda_2 = { "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", #"code": "物料编码001", - #"barCode": "物料条码001", + #"barCode": "物料条码001", "name": "mianfen2", "unit": "个", "quantity": 1, @@ -795,7 +1056,7 @@ if __name__ == "__main__": # material_data_sb_mpda = { # "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", # #"code": "物料编码001", - # #"barCode": "物料条码001", + # #"barCode": "物料条码001", # "name": "烧杯MPDA", # "unit": "个", # "quantity": 1, diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station.py b/unilabos/devices/workstation/bioyond_studio/reaction_station.py index f7cb0f8d..9060710e 100644 --- a/unilabos/devices/workstation/bioyond_studio/reaction_station.py +++ b/unilabos/devices/workstation/bioyond_studio/reaction_station.py @@ -208,7 +208,8 @@ class BioyondReactionStation(BioyondWorkstation): def liquid_feeding_solvents( self, assign_material_name: str, - volume: str, + volume: str = None, + solvents = None, titration_type: str = "1", time: str = "360", torque_variation: int = 2, @@ -218,12 +219,41 @@ class BioyondReactionStation(BioyondWorkstation): Args: assign_material_name: 物料名称 - volume: 分液量(μL) + volume: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算) + solvents: 溶剂信息的字典或JSON字符串(可选),格式如下: + { + "additional_solvent": 33.55092503597727, # 溶剂体积(mL) + "total_liquid_volume": 48.00916988195499 + } + 如果提供solvents,则从中提取additional_solvent并转换为μL titration_type: 是否滴定(1=否, 2=是) time: 观察时间(分钟) torque_variation: 是否观察(int类型, 1=否, 2=是) temperature: 温度设定(°C) """ + # 处理 volume 参数:优先使用直接传入的 volume,否则从 solvents 中提取 + if not volume and solvents is not None: + # 参数类型转换:如果是字符串则解析为字典 + if isinstance(solvents, str): + try: + solvents = json.loads(solvents) + except json.JSONDecodeError as e: + raise ValueError(f"solvents参数JSON解析失败: {str(e)}") + + # 参数验证 + if not isinstance(solvents, dict): + raise ValueError("solvents 必须是字典类型或有效的JSON字符串") + + # 提取 additional_solvent 值 + additional_solvent = solvents.get("additional_solvent") + if additional_solvent is None: + raise ValueError("solvents 中没有找到 additional_solvent 字段") + + # 转换为微升(μL) - 从毫升(mL)转换 + volume = str(float(additional_solvent) * 1000) + elif volume is None: + raise ValueError("必须提供 volume 或 solvents 参数之一") + self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding_solvents"}') material_id = self.hardware_interface._get_material_id_by_name(assign_material_name) if material_id is None: diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 21957cd2..8c9a8164 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -85,8 +85,90 @@ class BioyondResourceSynchronizer(ResourceSynchronizer): def sync_to_external(self, resource: Any) -> bool: """将本地物料数据变更同步到Bioyond系统""" try: - if self.bioyond_api_client is None: - logger.error("Bioyond API客户端未初始化") + # ✅ 跳过仓库类型的资源 - 仓库是容器,不是物料 + resource_category = getattr(resource, "category", None) + if resource_category == "warehouse": + logger.debug(f"[同步→Bioyond] 跳过仓库类型资源: {resource.name} (仓库是容器,不需要同步为物料)") + return True + + logger.info(f"[同步→Bioyond] 收到物料变更: {resource.name}") + + # 获取物料的 Bioyond ID + extra_info = getattr(resource, "unilabos_extra", {}) + material_bioyond_id = extra_info.get("material_bioyond_id") + + # ⭐ 如果没有 Bioyond ID,尝试从 Bioyond 系统中按名称查询 + if not material_bioyond_id: + logger.warning(f"[同步→Bioyond] 物料 {resource.name} 没有 Bioyond ID,尝试按名称查询...") + try: + # 查询所有类型的物料:0=耗材, 1=样品, 2=试剂 + import json + all_materials = [] + + for type_mode in [0, 1, 2]: + query_params = json.dumps({ + "typeMode": type_mode, + "filter": "", # 空字符串表示查询所有 + "includeDetail": True + }) + materials = self.bioyond_api_client.stock_material(query_params) + if materials: + all_materials.extend(materials) + + logger.info(f"[同步→Bioyond] 查询到 {len(all_materials)} 个物料") + + # 按名称匹配 + for mat in all_materials: + if mat.get("name") == resource.name: + material_bioyond_id = mat.get("id") + mat_type = mat.get("typeName", "未知") + logger.info(f"✅ 找到物料 {resource.name} ({mat_type}) 的 Bioyond ID: {material_bioyond_id[:8]}...") + # 保存 ID 到资源对象 + extra_info["material_bioyond_id"] = material_bioyond_id + setattr(resource, "unilabos_extra", extra_info) + break + + if not material_bioyond_id: + logger.warning(f"⚠️ 在 Bioyond 系统中未找到名为 {resource.name} 的物料") + logger.info(f"[同步→Bioyond] 这是一个新物料,将创建并入库到 Bioyond 系统") + # 不返回,继续执行后续的创建+入库流程 + except Exception as e: + logger.error(f"查询 Bioyond 物料失败: {e}") + import traceback + traceback.print_exc() + return False + + # 检查是否有位置更新请求 + update_site = extra_info.get("update_resource_site") + + if not update_site: + logger.debug(f"[同步→Bioyond] 无位置更新请求") + return True + + # ===== 物料移动/创建流程 ===== + if material_bioyond_id: + logger.info(f"[同步→Bioyond] 🔄 开始移动物料 {resource.name} 到 {update_site}") + else: + logger.info(f"[同步→Bioyond] ➕ 开始创建新物料 {resource.name} 并入库到 {update_site}") # 第1步:获取仓库配置 + from .config import WAREHOUSE_MAPPING + warehouse_mapping = WAREHOUSE_MAPPING + + # 确定目标仓库名称(通过遍历所有仓库的库位配置) + parent_name = None + target_location_uuid = None + + for warehouse_name, warehouse_info in warehouse_mapping.items(): + site_uuids = warehouse_info.get("site_uuids", {}) + if update_site in site_uuids: + parent_name = warehouse_name + target_location_uuid = site_uuids[update_site] + logger.info(f"[同步] 目标仓库: {parent_name}/{update_site}") + logger.info(f"[同步] 目标库位UUID: {target_location_uuid[:8]}...") + break + + if not parent_name or not target_location_uuid: + logger.error(f"❌ 库位 {update_site} 没有在 WAREHOUSE_MAPPING 中配置") + logger.debug(f"可用仓库: {list(warehouse_mapping.keys())}") return False bioyond_material = resource_plr_to_bioyond( @@ -171,11 +253,22 @@ class BioyondWorkstation(WorkstationBase): def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node + + # ⭐ 上传 deck(包括所有 warehouses 及其中的物料) + # 注意:如果有从 Bioyond 同步的物料,它们已经被放置到 warehouse 中了 + # 所以只需要上传 deck,物料会作为 warehouse 的 children 一起上传 + logger.info("正在上传 deck(包括 warehouses 和物料)到云端...") ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) + # 清理临时变量(物料已经在 deck 的 warehouse children 中,不需要单独上传) + if hasattr(self, "_synced_resources"): + logger.info(f"✅ {len(self._synced_resources)} 个从Bioyond同步的物料已包含在 deck 中") + self._synced_resources = [] + def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): + time.sleep(3) ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, "target_device_id": mount_device_id, @@ -246,7 +339,7 @@ class BioyondWorkstation(WorkstationBase): } # ==================== 工作流合并与参数设置 API ==================== - + def append_to_workflow_sequence(self, web_workflow_name: str) -> bool: # 检查是否为JSON格式的字符串 actual_workflow_name = web_workflow_name @@ -257,7 +350,7 @@ class BioyondWorkstation(WorkstationBase): print(f"解析JSON格式工作流名称: {web_workflow_name} -> {actual_workflow_name}") except json.JSONDecodeError: print(f"JSON解析失败,使用原始字符串: {web_workflow_name}") - + workflow_id = self._get_workflow(actual_workflow_name) if workflow_id: self.workflow_sequence.append(workflow_id) @@ -322,7 +415,7 @@ class BioyondWorkstation(WorkstationBase): # ============ 工作站状态管理 ============ def get_station_info(self) -> Dict[str, Any]: """获取工作站基础信息 - + Returns: Dict[str, Any]: 工作站基础信息,包括设备ID、状态等 """ @@ -450,8 +543,8 @@ class BioyondWorkstation(WorkstationBase): # 转换为UniLab格式 unilab_resources = resource_bioyond_to_plr( - bioyond_data, - type_mapping=self.bioyond_config["material_type_mappings"], + bioyond_data, + type_mapping=self.bioyond_config["material_type_mappings"], deck=self.deck ) diff --git a/unilabos/devices/workstation/workstation_material_management.py b/unilabos/devices/workstation/workstation_material_management.py deleted file mode 100644 index a9229130..00000000 --- a/unilabos/devices/workstation/workstation_material_management.py +++ /dev/null @@ -1,583 +0,0 @@ -""" -工作站物料管理基类 -Workstation Material Management Base Class - -基于PyLabRobot的物料管理系统 -""" -from typing import Dict, Any, List, Optional, Union, Type -from abc import ABC, abstractmethod -import json - -from pylabrobot.resources import ( - Resource as PLRResource, - Container, - Deck, - Coordinate as PLRCoordinate, -) - -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker -from unilabos.utils.log import logger -from unilabos.resources.graphio import resource_plr_to_ulab, resource_ulab_to_plr - - -class MaterialManagementBase(ABC): - """物料管理基类 - - 定义工作站物料管理的标准接口: - 1. 物料初始化 - 根据配置创建物料资源 - 2. 物料追踪 - 实时跟踪物料位置和状态 - 3. 物料查找 - 按类型、位置、状态查找物料 - 4. 物料转换 - PyLabRobot与UniLab资源格式转换 - """ - - def __init__( - self, - device_id: str, - deck_config: Dict[str, Any], - resource_tracker: DeviceNodeResourceTracker, - children_config: Dict[str, Dict[str, Any]] = None - ): - self.device_id = device_id - self.deck_config = deck_config - self.resource_tracker = resource_tracker - self.children_config = children_config or {} - - # 创建主台面 - self.plr_deck = self._create_deck() - - # 扩展ResourceTracker - self._extend_resource_tracker() - - # 注册deck到resource tracker - self.resource_tracker.add_resource(self.plr_deck) - - # 初始化子资源 - self.plr_resources = {} - self._initialize_materials() - - def _create_deck(self) -> Deck: - """创建主台面""" - return Deck( - name=f"{self.device_id}_deck", - size_x=self.deck_config.get("size_x", 1000.0), - size_y=self.deck_config.get("size_y", 1000.0), - size_z=self.deck_config.get("size_z", 500.0), - origin=PLRCoordinate(0, 0, 0) - ) - - def _extend_resource_tracker(self): - """扩展ResourceTracker以支持PyLabRobot特定功能""" - - def find_by_type(resource_type): - """按类型查找资源""" - return self._find_resources_by_type_recursive(self.plr_deck, resource_type) - - def find_by_category(category: str): - """按类别查找资源""" - found = [] - for resource in self._get_all_resources(): - if hasattr(resource, 'category') and resource.category == category: - found.append(resource) - return found - - def find_by_name_pattern(pattern: str): - """按名称模式查找资源""" - import re - found = [] - for resource in self._get_all_resources(): - if re.search(pattern, resource.name): - found.append(resource) - return found - - # 动态添加方法到resource_tracker - self.resource_tracker.find_by_type = find_by_type - self.resource_tracker.find_by_category = find_by_category - self.resource_tracker.find_by_name_pattern = find_by_name_pattern - - def _find_resources_by_type_recursive(self, resource, target_type): - """递归查找指定类型的资源""" - found = [] - if isinstance(resource, target_type): - found.append(resource) - - # 递归查找子资源 - children = getattr(resource, "children", []) - for child in children: - found.extend(self._find_resources_by_type_recursive(child, target_type)) - - return found - - def _get_all_resources(self) -> List[PLRResource]: - """获取所有资源""" - all_resources = [] - - def collect_resources(resource): - all_resources.append(resource) - children = getattr(resource, "children", []) - for child in children: - collect_resources(child) - - collect_resources(self.plr_deck) - return all_resources - - def _initialize_materials(self): - """初始化物料""" - try: - # 确定创建顺序,确保父资源先于子资源创建 - creation_order = self._determine_creation_order() - - # 按顺序创建资源 - for resource_id in creation_order: - config = self.children_config[resource_id] - self._create_plr_resource(resource_id, config) - - logger.info(f"物料管理系统初始化完成,共创建 {len(self.plr_resources)} 个资源") - - except Exception as e: - logger.error(f"物料初始化失败: {e}") - - def _determine_creation_order(self) -> List[str]: - """确定资源创建顺序""" - order = [] - visited = set() - - def visit(resource_id: str): - if resource_id in visited: - return - visited.add(resource_id) - - config = self.children_config.get(resource_id, {}) - parent_id = config.get("parent") - - # 如果有父资源,先访问父资源 - if parent_id and parent_id in self.children_config: - visit(parent_id) - - order.append(resource_id) - - for resource_id in self.children_config: - visit(resource_id) - - return order - - def _create_plr_resource(self, resource_id: str, config: Dict[str, Any]): - """创建PyLabRobot资源""" - try: - resource_type = config.get("type", "unknown") - data = config.get("data", {}) - location_config = config.get("location", {}) - - # 创建位置坐标 - location = PLRCoordinate( - x=location_config.get("x", 0.0), - y=location_config.get("y", 0.0), - z=location_config.get("z", 0.0) - ) - - # 根据类型创建资源 - resource = self._create_resource_by_type(resource_id, resource_type, config, data, location) - - if resource: - # 设置父子关系 - parent_id = config.get("parent") - if parent_id and parent_id in self.plr_resources: - parent_resource = self.plr_resources[parent_id] - parent_resource.assign_child_resource(resource, location) - else: - # 直接放在deck上 - self.plr_deck.assign_child_resource(resource, location) - - # 保存资源引用 - self.plr_resources[resource_id] = resource - - # 注册到resource tracker - self.resource_tracker.add_resource(resource) - - logger.debug(f"创建资源成功: {resource_id} ({resource_type})") - - except Exception as e: - logger.error(f"创建资源失败 {resource_id}: {e}") - - @abstractmethod - def _create_resource_by_type( - self, - resource_id: str, - resource_type: str, - config: Dict[str, Any], - data: Dict[str, Any], - location: PLRCoordinate - ) -> Optional[PLRResource]: - """根据类型创建资源 - 子类必须实现""" - pass - - # ============ 物料查找接口 ============ - - def find_materials_by_type(self, material_type: str) -> List[PLRResource]: - """按材料类型查找物料""" - return self.resource_tracker.find_by_category(material_type) - - def find_material_by_id(self, resource_id: str) -> Optional[PLRResource]: - """按ID查找物料""" - return self.plr_resources.get(resource_id) - - def find_available_positions(self, position_type: str) -> List[PLRResource]: - """查找可用位置""" - positions = self.resource_tracker.find_by_category(position_type) - available = [] - - for pos in positions: - if hasattr(pos, 'is_available') and pos.is_available(): - available.append(pos) - elif hasattr(pos, 'children') and len(pos.children) == 0: - available.append(pos) - - return available - - def get_material_inventory(self) -> Dict[str, int]: - """获取物料库存统计""" - inventory = {} - - for resource in self._get_all_resources(): - if hasattr(resource, 'category'): - category = resource.category - inventory[category] = inventory.get(category, 0) + 1 - - return inventory - - # ============ 物料状态更新接口 ============ - - def update_material_location(self, material_id: str, new_location: PLRCoordinate) -> bool: - """更新物料位置""" - try: - material = self.find_material_by_id(material_id) - if material: - material.location = new_location - return True - return False - except Exception as e: - logger.error(f"更新物料位置失败: {e}") - return False - - def move_material(self, material_id: str, target_container_id: str) -> bool: - """移动物料到目标容器""" - try: - material = self.find_material_by_id(material_id) - target = self.find_material_by_id(target_container_id) - - if material and target: - # 从原位置移除 - if material.parent: - material.parent.unassign_child_resource(material) - - # 添加到新位置 - target.assign_child_resource(material) - return True - - return False - - except Exception as e: - logger.error(f"移动物料失败: {e}") - return False - - # ============ 资源转换接口 ============ - - def convert_to_unilab_format(self, plr_resource: PLRResource) -> Dict[str, Any]: - """将PyLabRobot资源转换为UniLab格式""" - return resource_plr_to_ulab(plr_resource) - - def convert_from_unilab_format(self, unilab_resource: Dict[str, Any]) -> PLRResource: - """将UniLab格式转换为PyLabRobot资源""" - return resource_ulab_to_plr(unilab_resource) - - def get_deck_state(self) -> Dict[str, Any]: - """获取Deck状态""" - try: - return { - "deck_info": { - "name": self.plr_deck.name, - "size": { - "x": self.plr_deck.size_x, - "y": self.plr_deck.size_y, - "z": self.plr_deck.size_z - }, - "children_count": len(self.plr_deck.children) - }, - "resources": { - resource_id: self.convert_to_unilab_format(resource) - for resource_id, resource in self.plr_resources.items() - }, - "inventory": self.get_material_inventory() - } - except Exception as e: - logger.error(f"获取Deck状态失败: {e}") - return {"error": str(e)} - - # ============ 数据持久化接口 ============ - - def save_state_to_file(self, file_path: str) -> bool: - """保存状态到文件""" - try: - state = self.get_deck_state() - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(state, f, indent=2, ensure_ascii=False) - logger.info(f"状态已保存到: {file_path}") - return True - except Exception as e: - logger.error(f"保存状态失败: {e}") - return False - - def load_state_from_file(self, file_path: str) -> bool: - """从文件加载状态""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - state = json.load(f) - - # 重新创建资源 - self._recreate_resources_from_state(state) - logger.info(f"状态已从文件加载: {file_path}") - return True - - except Exception as e: - logger.error(f"加载状态失败: {e}") - return False - - def _recreate_resources_from_state(self, state: Dict[str, Any]): - """从状态重新创建资源""" - # 清除现有资源 - self.plr_resources.clear() - self.plr_deck.children.clear() - - # 从状态重新创建 - resources_data = state.get("resources", {}) - for resource_id, resource_data in resources_data.items(): - try: - plr_resource = self.convert_from_unilab_format(resource_data) - self.plr_resources[resource_id] = plr_resource - self.plr_deck.assign_child_resource(plr_resource) - except Exception as e: - logger.error(f"重新创建资源失败 {resource_id}: {e}") - - -class CoinCellMaterialManagement(MaterialManagementBase): - """纽扣电池物料管理类 - - 从 button_battery_station 抽取的物料管理功能 - """ - - def _create_resource_by_type( - self, - resource_id: str, - resource_type: str, - config: Dict[str, Any], - data: Dict[str, Any], - location: PLRCoordinate - ) -> Optional[PLRResource]: - """根据类型创建纽扣电池相关资源""" - - # 导入纽扣电池资源类 - from unilabos.device_comms.button_battery_station import ( - MaterialPlate, PlateSlot, ClipMagazine, BatteryPressSlot, - TipBox64, WasteTipBox, BottleRack, Battery, ElectrodeSheet - ) - - try: - if resource_type == "material_plate": - return self._create_material_plate(resource_id, config, data, location) - - elif resource_type == "plate_slot": - return self._create_plate_slot(resource_id, config, data, location) - - elif resource_type == "clip_magazine": - return self._create_clip_magazine(resource_id, config, data, location) - - elif resource_type == "battery_press_slot": - return self._create_battery_press_slot(resource_id, config, data, location) - - elif resource_type == "tip_box": - return self._create_tip_box(resource_id, config, data, location) - - elif resource_type == "waste_tip_box": - return self._create_waste_tip_box(resource_id, config, data, location) - - elif resource_type == "bottle_rack": - return self._create_bottle_rack(resource_id, config, data, location) - - elif resource_type == "battery": - return self._create_battery(resource_id, config, data, location) - - else: - logger.warning(f"未知的资源类型: {resource_type}") - return None - - except Exception as e: - logger.error(f"创建资源失败 {resource_id} ({resource_type}): {e}") - return None - - def _create_material_plate(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建料板""" - from unilabos.device_comms.button_battery_station import MaterialPlate, ElectrodeSheet - - plate = MaterialPlate( - name=resource_id, - size_x=config.get("size_x", 80.0), - size_y=config.get("size_y", 80.0), - size_z=config.get("size_z", 10.0), - hole_diameter=config.get("hole_diameter", 15.0), - hole_depth=config.get("hole_depth", 8.0), - hole_spacing_x=config.get("hole_spacing_x", 20.0), - hole_spacing_y=config.get("hole_spacing_y", 20.0), - number=data.get("number", "") - ) - plate.location = location - - # 如果有预填充的极片数据,创建极片 - electrode_sheets = data.get("electrode_sheets", []) - for i, sheet_data in enumerate(electrode_sheets): - if i < len(plate.children): # 确保不超过洞位数量 - hole = plate.children[i] - sheet = ElectrodeSheet( - name=f"{resource_id}_sheet_{i}", - diameter=sheet_data.get("diameter", 14.0), - thickness=sheet_data.get("thickness", 0.1), - mass=sheet_data.get("mass", 0.01), - material_type=sheet_data.get("material_type", "cathode"), - info=sheet_data.get("info", "") - ) - hole.place_electrode_sheet(sheet) - - return plate - - def _create_plate_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建板槽位""" - from unilabos.device_comms.button_battery_station import PlateSlot - - slot = PlateSlot( - name=resource_id, - max_plates=config.get("max_plates", 8) - ) - slot.location = location - return slot - - def _create_clip_magazine(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建子弹夹""" - from unilabos.device_comms.button_battery_station import ClipMagazine - - magazine = ClipMagazine( - name=resource_id, - size_x=config.get("size_x", 150.0), - size_y=config.get("size_y", 100.0), - size_z=config.get("size_z", 50.0), - hole_diameter=config.get("hole_diameter", 15.0), - hole_depth=config.get("hole_depth", 40.0), - hole_spacing=config.get("hole_spacing", 25.0), - max_sheets_per_hole=config.get("max_sheets_per_hole", 100) - ) - magazine.location = location - return magazine - - def _create_battery_press_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建电池压制槽""" - from unilabos.device_comms.button_battery_station import BatteryPressSlot - - slot = BatteryPressSlot( - name=resource_id, - diameter=config.get("diameter", 20.0), - depth=config.get("depth", 15.0) - ) - slot.location = location - return slot - - def _create_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建枪头盒""" - from unilabos.device_comms.button_battery_station import TipBox64 - - tip_box = TipBox64( - name=resource_id, - size_x=config.get("size_x", 127.8), - size_y=config.get("size_y", 85.5), - size_z=config.get("size_z", 60.0), - with_tips=data.get("with_tips", True) - ) - tip_box.location = location - return tip_box - - def _create_waste_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建废枪头盒""" - from unilabos.device_comms.button_battery_station import WasteTipBox - - waste_box = WasteTipBox( - name=resource_id, - size_x=config.get("size_x", 127.8), - size_y=config.get("size_y", 85.5), - size_z=config.get("size_z", 60.0), - max_tips=config.get("max_tips", 100) - ) - waste_box.location = location - return waste_box - - def _create_bottle_rack(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建瓶架""" - from unilabos.device_comms.button_battery_station import BottleRack - - rack = BottleRack( - name=resource_id, - size_x=config.get("size_x", 210.0), - size_y=config.get("size_y", 140.0), - size_z=config.get("size_z", 100.0), - bottle_diameter=config.get("bottle_diameter", 30.0), - bottle_height=config.get("bottle_height", 100.0), - position_spacing=config.get("position_spacing", 35.0) - ) - rack.location = location - return rack - - def _create_battery(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建电池""" - from unilabos.device_comms.button_battery_station import Battery - - battery = Battery( - name=resource_id, - diameter=config.get("diameter", 20.0), - height=config.get("height", 3.2), - max_volume=config.get("max_volume", 100.0), - barcode=data.get("barcode", "") - ) - battery.location = location - return battery - - # ============ 纽扣电池特定查找方法 ============ - - def find_material_plates(self): - """查找所有料板""" - from unilabos.device_comms.button_battery_station import MaterialPlate - return self.resource_tracker.find_by_type(MaterialPlate) - - def find_batteries(self): - """查找所有电池""" - from unilabos.device_comms.button_battery_station import Battery - return self.resource_tracker.find_by_type(Battery) - - def find_electrode_sheets(self): - """查找所有极片""" - found = [] - plates = self.find_material_plates() - for plate in plates: - for hole in plate.children: - if hasattr(hole, 'has_electrode_sheet') and hole.has_electrode_sheet(): - found.append(hole._electrode_sheet) - return found - - def find_plate_slots(self): - """查找所有板槽位""" - from unilabos.device_comms.button_battery_station import PlateSlot - return self.resource_tracker.find_by_type(PlateSlot) - - def find_clip_magazines(self): - """查找所有子弹夹""" - from unilabos.device_comms.button_battery_station import ClipMagazine - return self.resource_tracker.find_by_type(ClipMagazine) - - def find_press_slots(self): - """查找所有压制槽""" - from unilabos.device_comms.button_battery_station import BatteryPressSlot - return self.resource_tracker.find_by_type(BatteryPressSlot) diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index 6250db75..50a94be9 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -5,6 +5,157 @@ bioyond_dispensing_station: - bioyond_dispensing_station class: action_value_mappings: + batch_create_90_10_vial_feeding_tasks: + feedback: {} + goal: + delay_time: delay_time + hold_m_name: hold_m_name + liquid_material_name: liquid_material_name + speed: speed + temperature: temperature + titration: titration + goal_default: + delay_time: '600' + hold_m_name: '' + liquid_material_name: NMP + speed: '400' + temperature: '40' + titration: '' + handles: + input: + - data_key: titration + data_source: handle + data_type: object + handler_key: titration + io_type: source + label: Titration Data From Calculation Node + result: + return_info: return_info + schema: + description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。 + properties: + feedback: + properties: {} + required: [] + title: BatchCreate9010VialFeedingTasks_Feedback + type: object + goal: + properties: + delay_time: + default: '600' + description: 延迟时间(秒),默认600 + type: string + hold_m_name: + description: 库位名称,如"C01",必填参数 + type: string + liquid_material_name: + default: NMP + description: 10%物料的液体物料名称,默认为"NMP" + type: string + speed: + default: '400' + description: 搅拌速度,默认400 + type: string + temperature: + default: '40' + description: 温度(℃),默认40 + type: string + titration: + description: '滴定信息对象,包含: name(物料名称), main_portion(主称固体质量g), titration_portion(滴定固体质量g), + titration_solvent(滴定溶液体积mL)' + type: string + required: + - titration + - hold_m_name + title: BatchCreate9010VialFeedingTasks_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: BatchCreate9010VialFeedingTasks_Result + type: object + required: + - goal + title: BatchCreate9010VialFeedingTasks + type: object + type: UniLabJsonCommand + batch_create_diamine_solution_tasks: + feedback: {} + goal: + delay_time: delay_time + liquid_material_name: liquid_material_name + solutions: solutions + speed: speed + temperature: temperature + goal_default: + delay_time: '600' + liquid_material_name: NMP + solutions: '' + speed: '400' + temperature: '20' + handles: + input: + - data_key: solutions + data_source: handle + data_type: array + handler_key: solutions + io_type: source + label: Solution Data From Python + result: + return_info: return_info + schema: + description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。 + properties: + feedback: + properties: {} + required: [] + title: BatchCreateDiamineSolutionTasks_Feedback + type: object + goal: + properties: + delay_time: + default: '600' + description: 溶液配置完成后的延迟时间(秒),用于充分混合和溶解,默认600秒 + type: string + liquid_material_name: + default: NMP + description: 液体溶剂名称,用于溶解固体物料,默认为NMP(N-甲基吡咯烷酮) + type: string + solutions: + description: '溶液列表,JSON数组格式,每个元素包含: name(物料名称), order(序号), solid_mass(固体质量g), + solvent_volume(溶剂体积mL)。示例: [{"name": "MDA", "order": 0, "solid_mass": + 5.0, "solvent_volume": 20}, {"name": "MPDA", "order": 1, "solid_mass": + 4.5, "solvent_volume": 18}]' + type: string + speed: + default: '400' + description: 搅拌速度(rpm),用于混合溶液,默认400转/分钟 + type: string + temperature: + default: '20' + description: 配置温度(℃),溶液配置过程的目标温度,默认20℃(室温) + type: string + required: + - solutions + title: BatchCreateDiamineSolutionTasks_Goal + type: object + result: + properties: + return_info: + description: 批量任务创建结果汇总,JSON格式包含总数、成功数、失败数及每个任务的详细信息 + type: string + required: + - return_info + title: BatchCreateDiamineSolutionTasks_Result + type: object + required: + - goal + title: BatchCreateDiamineSolutionTasks + type: object + type: UniLabJsonCommand create_90_10_vial_feeding_task: feedback: {} goal: diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index f8719e57..68007cb3 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -120,6 +120,7 @@ reaction_station.bioyond: feedback: {} goal: assign_material_name: assign_material_name + solvents: solvents temperature: temperature time: time titration_type: titration_type @@ -127,15 +128,23 @@ reaction_station.bioyond: volume: volume goal_default: assign_material_name: '' - temperature: '' - time: '' - titration_type: '' - torque_variation: '' + solvents: '' + temperature: '25.00' + time: '360' + titration_type: '1' + torque_variation: '2' volume: '' - handles: {} + handles: + input: + - data_key: solvents + data_source: handle + data_type: object + handler_key: solvents + io_type: source + label: Solvents Data From Calculation Node result: {} schema: - description: 液体投料-溶剂 + description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。 properties: feedback: {} goal: @@ -143,28 +152,30 @@ reaction_station.bioyond: assign_material_name: description: 物料名称 type: string + solvents: + description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' + type: string temperature: - description: 温度设定(°C) + default: '25.00' + description: 温度设定(°C),默认25.00 type: string time: - description: 观察时间(分钟) + default: '360' + description: 观察时间(分钟),默认360 type: string titration_type: - description: 是否滴定(1=否, 2=是) + default: '1' + description: 是否滴定(1=否, 2=是),默认1 type: string torque_variation: - description: 是否观察 (1=否, 2=是) + default: '2' + description: 是否观察 (1=否, 2=是),默认2 type: string volume: - description: 分液公式(μL) + description: 分液量(μL)。可直接提供,或通过solvents参数自动计算 type: string required: - - volume - assign_material_name - - time - - torque_variation - - titration_type - - temperature type: object result: {} required: diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index 55da6908..b3438ccf 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -48,3 +48,25 @@ BIOYOND_PolymerStation_Solution_Beaker: icon: '' init_param_schema: {} version: 1.0.0 +BIOYOND_PolymerStation_TipBox: + category: + - bottles + - tip_boxes + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Reactor: + category: + - bottles + - reactors + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reactor + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index 7d241a73..c509e883 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -90,3 +90,89 @@ def BIOYOND_PolymerStation_Reagent_Bottle( barcode=barcode, model="BIOYOND_PolymerStation_Reagent_Bottle", ) + + +def BIOYOND_PolymerStation_Reactor( + name: str, + diameter: float = 30.0, + height: float = 80.0, + max_volume: float = 50000.0, # 50mL + barcode: str = None, +) -> Bottle: + """创建反应器""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Reactor", + ) + + +def BIOYOND_PolymerStation_TipBox( + name: str, + size_x: float = 127.76, # 枪头盒宽度 + size_y: float = 85.48, # 枪头盒长度 + size_z: float = 100.0, # 枪头盒高度 + barcode: str = None, +): + """创建4×6枪头盒 (24个枪头) + + Args: + name: 枪头盒名称 + size_x: 枪头盒宽度 (mm) + size_y: 枪头盒长度 (mm) + size_z: 枪头盒高度 (mm) + barcode: 条形码 + + Returns: + TipBoxCarrier: 包含24个枪头孔位的枪头盒 + """ + from pylabrobot.resources import Container, Coordinate + + # 创建枪头盒容器 + tip_box = Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="tip_rack", + model="BIOYOND_PolymerStation_TipBox_4x6", + ) + + # 设置自定义属性 + tip_box.barcode = barcode + tip_box.tip_count = 24 # 4行×6列 + tip_box.num_items_x = 6 # 6列 + tip_box.num_items_y = 4 # 4行 + + # 创建24个枪头孔位 (4行×6列) + # 假设孔位间距为 9mm + tip_spacing_x = 9.0 # 列间距 + tip_spacing_y = 9.0 # 行间距 + start_x = 14.38 # 第一个孔位的x偏移 + start_y = 11.24 # 第一个孔位的y偏移 + + for row in range(4): # A, B, C, D + for col in range(6): # 1-6 + spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6 + x = start_x + col * tip_spacing_x + y = start_y + row * tip_spacing_y + + # 创建枪头孔位容器 + tip_spot = Container( + name=spot_name, + size_x=8.0, # 单个枪头孔位大小 + size_y=8.0, + size_z=size_z - 10.0, # 略低于盒子高度 + category="tip_spot", + ) + + # 添加到枪头盒 + tip_box.assign_child_resource( + tip_spot, + location=Coordinate(x=x, y=y, z=0) + ) + + return tip_box diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index fa242c3d..187f17e8 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -1,12 +1,27 @@ from os import name from pylabrobot.resources import Deck, Coordinate, Rotation -from unilabos.resources.bioyond.warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling, bioyond_warehouse_1x2x2, bioyond_warehouse_1x3x3, bioyond_warehouse_10x1x1, bioyond_warehouse_3x3x1, bioyond_warehouse_3x3x1_2, bioyond_warehouse_5x1x1 +from unilabos.resources.bioyond.warehouses import ( + bioyond_warehouse_1x4x4, + bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08) + bioyond_warehouse_1x4x2, + bioyond_warehouse_liquid_and_lid_handling, + bioyond_warehouse_1x2x2, + bioyond_warehouse_1x3x3, + bioyond_warehouse_10x1x1, + bioyond_warehouse_3x3x1, + bioyond_warehouse_3x3x1_2, + bioyond_warehouse_5x1x1, + bioyond_warehouse_1x8x4, + bioyond_warehouse_reagent_storage, + bioyond_warehouse_liquid_preparation, + bioyond_warehouse_tipbox_storage, # 新增:Tip盒堆栈 +) class BIOYOND_PolymerReactionStation_Deck(Deck): def __init__( - self, + self, name: str = "PolymerReactionStation_Deck", size_x: float = 2700.0, size_y: float = 1080.0, @@ -20,15 +35,22 @@ class BIOYOND_PolymerReactionStation_Deck(Deck): def setup(self) -> None: # 添加仓库 + # 说明: 堆栈1物理上分为左右两部分 + # - 堆栈1左: A01~D04 (4行×4列, 位于反应站左侧) + # - 堆栈1右: A05~D08 (4行×4列, 位于反应站右侧) self.warehouses = { - "堆栈1": bioyond_warehouse_1x4x4("堆栈1"), - "堆栈2": bioyond_warehouse_1x4x4("堆栈2"), - "站内试剂存放堆栈": bioyond_warehouse_liquid_and_lid_handling("站内试剂存放堆栈"), + "堆栈1左": bioyond_warehouse_1x4x4("堆栈1左"), # 左侧堆栈: A01~D04 + "堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05~D08 + "站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01~A02 + "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01~B04 + "站内Tip盒堆栈": bioyond_warehouse_tipbox_storage("站内Tip盒堆栈"), # A01~B03, 存放枪头盒 } self.warehouse_locations = { - "堆栈1": Coordinate(0.0, 430.0, 0.0), - "堆栈2": Coordinate(2550.0, 430.0, 0.0), - "站内试剂存放堆栈": Coordinate(800.0, 475.0, 0.0), + "堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置 + "堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置 + "站内试剂存放堆栈": Coordinate(1100.0, 475.0, 0.0), + "移液站内10%分装液体准备仓库": Coordinate(1500.0, 300.0, 0.0), + "站内Tip盒堆栈": Coordinate(1800.0, 300.0, 0.0), # TODO: 根据实际位置调整坐标 } self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90) @@ -38,7 +60,7 @@ class BIOYOND_PolymerReactionStation_Deck(Deck): class BIOYOND_PolymerPreparationStation_Deck(Deck): def __init__( - self, + self, name: str = "PolymerPreparationStation_Deck", size_x: float = 2700.0, size_y: float = 1080.0, @@ -70,7 +92,7 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck): class BIOYOND_YB_Deck(Deck): def __init__( - self, + self, name: str = "YB_Deck", size_x: float = 4150, size_y: float = 1400.0, @@ -114,7 +136,7 @@ class BIOYOND_YB_Deck(Deck): for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) - + def YB_Deck(name: str) -> Deck: by=BIOYOND_YB_Deck(name=name) by.setup() diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index a4bd46c4..66697be6 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -2,7 +2,7 @@ from unilabos.resources.warehouse import WareHouse, warehouse_factory def bioyond_warehouse_1x4x4(name: str) -> WareHouse: - """创建BioYond 4x1x4仓库""" + """创建BioYond 4x4x1仓库 (左侧堆栈: A01~D04)""" return warehouse_factory( name=name, num_items_x=4, @@ -15,6 +15,25 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse: item_dy=106.0, item_dz=130.0, category="warehouse", + col_offset=0, # 从01开始: A01, A02, A03, A04 + ) + + +def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse: + """创建BioYond 4x4x1仓库 (右侧堆栈: A05~D08)""" + return warehouse_factory( + name=name, + num_items_x=4, + num_items_y=4, + num_items_z=1, + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=147.0, + item_dy=106.0, + item_dz=130.0, + category="warehouse", + col_offset=4, # 从05开始: A05, A06, A07, A08 ) @@ -158,4 +177,72 @@ def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse: item_dz=120.0, category="warehouse", removed_positions=None + ) + + +def bioyond_warehouse_1x8x4(name: str) -> WareHouse: + """创建BioYond 8x4x1反应站堆栈(A01~D08)""" + return warehouse_factory( + name=name, + num_items_x=8, # 8列(01-08) + num_items_y=4, # 4行(A-D) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=147.0, + item_dy=106.0, + item_dz=130.0, + category="warehouse", + ) + + +def bioyond_warehouse_reagent_storage(name: str) -> WareHouse: + """创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)""" + return warehouse_factory( + name=name, + num_items_x=2, # 2列(01-02) + num_items_y=1, # 1行(A) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=137.0, + item_dy=96.0, + item_dz=120.0, + category="warehouse", + ) + + +def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse: + """创建BioYond移液站内10%分装液体准备仓库(A01~B04)""" + return warehouse_factory( + name=name, + num_items_x=4, # 4列(01-04) + num_items_y=2, # 2行(A-B) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=137.0, + item_dy=96.0, + item_dz=120.0, + category="warehouse", + ) + + +def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse: + """创建BioYond站内Tip盒堆栈(A01~B03),用于存放枪头盒""" + return warehouse_factory( + name=name, + num_items_x=3, # 3列(01-03) + num_items_y=2, # 2行(A-B) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=137.0, + item_dy=96.0, + item_dz=120.0, + category="warehouse", ) \ No newline at end of file diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index b6cec380..28edcae4 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -580,6 +580,8 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w "trash": "trash", "deck": "deck", "tip_rack": "tip_rack", + "warehouse": "warehouse", + "container": "container", } if source in replace_info: return replace_info[source] @@ -632,9 +634,24 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st type_mapping.get(material.get("typeName"), ("RegularContainer", ""))[0] if type_mapping else "RegularContainer" ) - plr_material: ResourcePLR = initialize_resource( + plr_material_result = initialize_resource( {"name": material["name"], "class": className}, resource_type=ResourcePLR ) + + # initialize_resource 可能返回列表或单个对象 + if isinstance(plr_material_result, list): + if len(plr_material_result) == 0: + logger.warning(f"物料 {material['name']} 初始化失败,跳过") + continue + plr_material = plr_material_result[0] + else: + plr_material = plr_material_result + + # 确保 plr_material 是 ResourcePLR 实例 + if not isinstance(plr_material, ResourcePLR): + logger.warning(f"物料 {material['name']} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") + continue + plr_material.code = material.get("code", "") and material.get("barCode", "") or "" plr_material.unilabos_uuid = str(uuid.uuid4()) @@ -659,25 +676,66 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st ] bottle.code = detail.get("code", "") else: - bottle = plr_material[0] if plr_material.capacity > 0 else plr_material - bottle.tracker.liquids = [ - (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) - ] + # 只对有 capacity 属性的容器(液体容器)处理液体追踪 + if hasattr(plr_material, 'capacity'): + bottle = plr_material[0] if plr_material.capacity > 0 else plr_material + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) if deck and hasattr(deck, "warehouses"): for loc in material.get("locations", []): - if hasattr(deck, "warehouses") and loc.get("whName") in deck.warehouses: - warehouse = deck.warehouses[loc["whName"]] - idx = ( - (loc.get("y", 0) - 1) * warehouse.num_items_x * warehouse.num_items_y - + (loc.get("x", 0) - 1) * warehouse.num_items_x - + (loc.get("z", 0) - 1) - ) + wh_name = loc.get("whName") + + # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" + # 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 + if wh_name == "堆栈1": + x_val = loc.get("x", 1) + if 1 <= x_val <= 4: + wh_name = "堆栈1左" + elif 5 <= x_val <= 8: + wh_name = "堆栈1右" + else: + logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右") + continue + + if hasattr(deck, "warehouses") and wh_name in deck.warehouses: + warehouse = deck.warehouses[wh_name] + + # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) + # PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ... + x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) + y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) + z = loc.get("z", 1) # 层号 (1-based, 通常为1) + + # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) + if wh_name == "堆栈1右": + y = y - 4 # 将5-8映射到1-4 + + # 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈) + # Bioyond的y坐标表示线性位置序号,而不是列号 + if warehouse.num_items_y == 1: + # 1行warehouse: 直接用y作为线性索引 + idx = y - 1 + logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}") + else: + # 多行warehouse: 使用列优先索引 (与Bioyond坐标系统一致) + # warehouse keys顺序: A01,B01,C01,D01, A02,B02,C02,D02, ... + # 索引计算: idx = (col-1) * num_rows + (row-1) + (layer-1) * (rows * cols) + row_idx = x - 1 # x表示行: 转为0-based + col_idx = y - 1 # y表示列: 转为0-based + layer_idx = z - 1 # 转为0-based + idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx + logger.debug(f"多行warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") + if 0 <= idx < warehouse.capacity: if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder): warehouse[idx] = plr_material + logger.debug(f"✅ 物料 {material['name']} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})") + else: + logger.warning(f"物料 {material['name']} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") return plr_materials @@ -714,8 +772,8 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict bottle = resource[0] if resource.capacity > 0 else resource material = { "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", - "name": resource.get("name", ""), - "unit": "", + "name": resource.name if hasattr(resource, "name") else "", + "unit": "个", # 修复:Bioyond API 要求 unit 字段不能为空 "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "Parameters": "{}" } @@ -759,6 +817,8 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni elif type(resource_class_config) == str: # Allow special resource class names to be used if resource_class_config not in lab_registry.resource_type_registry: + logger.warning(f"❌ 类 {resource_class_config} 不在 registry 中,返回原始配置") + logger.debug(f" 可用的类: {list(lab_registry.resource_type_registry.keys())[:10]}...") return [resource_config] # If the resource class is a string, look up the class in the # resource_type_registry and import it diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index c665b7fa..198971d0 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -23,6 +23,7 @@ def warehouse_factory( empty: bool = False, category: str = "warehouse", model: Optional[str] = None, + col_offset: int = 0, # 新增:列起始偏移量,用于生成A05-D08等命名 ): # 创建16个板架位 (4层 x 4位置) locations = [] @@ -44,9 +45,11 @@ def warehouse_factory( name_prefix=name, ) len_x, len_y = (num_items_x, num_items_y) if num_items_z == 1 else (num_items_y, num_items_z) if num_items_x == 1 else (num_items_x, num_items_z) - keys = [f"{LETTERS[j]}{i + 1}" for i in range(len_x) for j in range(len_y)] + # 应用列偏移量,支持A05-D08等命名 + # 使用列优先顺序生成keys (与Bioyond坐标系统一致): A01,B01,C01,D01, A02,B02,C02,D02, ... + keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)] sites = {i: site for i, site in zip(keys, _sites.values())} - + return WareHouse( name=name, size_x=dx + item_dx * num_items_x, diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index c958fe7b..020e966b 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -848,9 +848,15 @@ class DeviceNodeResourceTracker(object): extra: extra字典值 """ if isinstance(resource, dict): - resource["extra"] = extra + # ⭐ 修复:合并extra而不是覆盖 + current_extra = resource.get("extra", {}) + current_extra.update(extra) + resource["extra"] = current_extra else: - setattr(resource, "unilabos_extra", extra) + # ⭐ 修复:合并unilabos_extra而不是覆盖 + current_extra = getattr(resource, "unilabos_extra", {}) + current_extra.update(extra) + setattr(resource, "unilabos_extra", current_extra) def _traverse_and_process(self, resource, process_func) -> int: """