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 自研常量有机工站:最重要的是子设备管理和通信转发
+
+
+
+
+
+这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合;
+
+1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如2个泵共用1个串口、所有设备共同接入PLC等。
+2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json并管理执行、同时更改物料信息
+3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息
+
+### 0.2 移液工作站:物料系统和工作流模板管理
+
+
+
+1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供
+2. 所有任务系统均由工作站本身实现并下发指令,有统一的抽象函数可实现(pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。
+3. 物料系统为固定的板位系统:台面上有多个可摆放位置,摆放标准孔板。
+
+### 0.3 厂家开发的定制大型工站
+
+
+
+由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 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:
"""