diff --git a/docs/concepts/02-topology-and-chemputer-compile.md b/docs/concepts/02-topology-and-chemputer-compile.md index 9e40bad9..3fc18e23 100644 --- a/docs/concepts/02-topology-and-chemputer-compile.md +++ b/docs/concepts/02-topology-and-chemputer-compile.md @@ -19,7 +19,7 @@ Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包 对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。** 而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。 -于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下: +于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`WorkstationNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下: ![topology](image/02-topology-and-chemputer-compile/topology.png) diff --git a/docs/developer_guide/workstation_architecture.md b/docs/developer_guide/workstation_architecture.md new file mode 100644 index 00000000..f9d113e2 --- /dev/null +++ b/docs/developer_guide/workstation_architecture.md @@ -0,0 +1,378 @@ +# 工作站基础架构设计文档 + +## 1. 整体架构图 + +```mermaid +graph TB + subgraph "工作站基础架构" + WB[WorkstationBase] + WB --> |继承| RPN[ROS2WorkstationNode] + WB --> |组合| WCB[WorkstationCommunicationBase] + WB --> |组合| MMB[MaterialManagementBase] + WB --> |组合| WHS[WorkstationHTTPService] + end + + subgraph "通信层实现" + WCB --> |实现| PLC[PLCCommunication] + WCB --> |实现| SER[SerialCommunication] + WCB --> |实现| ETH[EthernetCommunication] + end + + subgraph "物料管理实现" + MMB --> |实现| PLR[PyLabRobotMaterialManager] + MMB --> |实现| BIO[BioyondMaterialManager] + MMB --> |实现| SIM[SimpleMaterialManager] + end + + subgraph "HTTP服务" + WHS --> |处理| LIMS[LIMS协议报送] + WHS --> |处理| MAT[物料变更报送] + WHS --> |处理| ERR[错误处理报送] + end + + subgraph "具体工作站实现" + WB --> |继承| WS1[PLCWorkstation] + WB --> |继承| WS2[ReportingWorkstation] + WB --> |继承| WS3[HybridWorkstation] + end + + subgraph "外部系统" + EXT1[PLC设备] --> |通信| PLC + EXT2[外部工作站] --> |HTTP报送| WHS + EXT3[LIMS系统] --> |HTTP报送| WHS + EXT4[Bioyond物料系统] --> |查询| BIO + end +``` + +## 2. 类关系图 + +```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() + +get_device_status() + } + + 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 + +resource_tracker: DeviceNodeResourceTracker + +plr_deck: Deck + +find_materials_by_type() + +update_material_location() + +convert_to_unilab_format() + +_create_resource_by_type()* + } + + class WorkstationHTTPService { + +workstation_instance: WorkstationBase + +host: str + +port: int + +start() + +stop() + +_handle_step_finish_report() + +_handle_material_change_report() + } + + class PLCWorkstation { + +plc_config: Dict + +modbus_client: ModbusTCPClient + +_create_communication_module() + +_create_material_management_module() + +_register_supported_workflows() + } + + class ReportingWorkstation { + +report_handlers: Dict + +_create_communication_module() + +_create_material_management_module() + +_register_supported_workflows() + } + + WorkstationBase --|> ROS2WorkstationNode + WorkstationBase *-- WorkstationCommunicationBase + WorkstationBase *-- MaterialManagementBase + WorkstationBase *-- WorkstationHTTPService + + PLCWorkstation --|> WorkstationBase + ReportingWorkstation --|> WorkstationBase + + WorkstationCommunicationBase <|-- PLCCommunication + WorkstationCommunicationBase <|-- DummyCommunication + + MaterialManagementBase <|-- PyLabRobotMaterialManager + MaterialManagementBase <|-- SimpleMaterialManager +``` + +## 3. 工作站启动时序图 + +```mermaid +sequenceDiagram + participant APP as Application + participant WS as WorkstationBase + participant COMM as CommunicationModule + participant MAT as MaterialManager + participant HTTP as HTTPService + participant ROS as ROS2WorkstationNode + + APP->>WS: 创建工作站实例 + WS->>ROS: 初始化ROS2WorkstationNode + ROS->>ROS: 初始化子设备 + 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: 工作站初始化完成 +``` + +## 4. 工作流执行时序图 + +```mermaid +sequenceDiagram + participant EXT as ExternalSystem + participant WS as WorkstationBase + participant COMM as CommunicationModule + participant MAT as MaterialManager + participant ROS as ROS2WorkstationNode + participant DEV as SubDevice + + 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: 更新完成 + end + + WS->>COMM: get_workflow_status() + COMM->>COMM: 查询PLC状态寄存器 + COMM-->>WS: 返回状态信息 + + WS->>WS: 更新workflow_status = COMPLETED + WS-->>EXT: 工作流执行完成 +``` + +## 5. HTTP报送处理时序图 + +```mermaid +sequenceDiagram + participant EXT as ExternalWorkstation + participant HTTP as HTTPService + participant WS as WorkstationBase + participant MAT as MaterialManager + participant DB as DataStorage + + EXT->>HTTP: POST /report/step_finish + HTTP->>HTTP: 解析请求数据 + HTTP->>HTTP: 验证LIMS协议字段 + HTTP->>WS: process_step_finish_report(request) + + WS->>WS: 增加接收计数 + WS->>WS: 记录步骤完成事件 + WS->>MAT: 更新相关物料状态 + MAT->>MAT: 更新PyLabRobot资源 + MAT-->>WS: 更新完成 + + WS->>DB: 保存报送记录 + DB-->>WS: 保存完成 + + WS-->>HTTP: 返回处理结果 + HTTP->>HTTP: 构造HTTP响应 + HTTP-->>EXT: 200 OK + acknowledgment_id + + Note over EXT,DB: 类似处理sample_finish, order_finish, material_change等报送 +``` + +## 6. 错误处理时序图 + +```mermaid +sequenceDiagram + participant DEV as Device + participant WS as WorkstationBase + participant COMM as CommunicationModule + 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: 触发重试逻辑 + end + + WS->>HTTP: 记录错误报送 + HTTP->>EXT: 主动通知错误状态 + + WS-->>DEV: 错误处理完成 +``` + +## 7. 典型工作站实现示例 + +### 7.1 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 _register_supported_workflows(self): + 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(...) + } +``` + +## 8. 核心接口说明 + +### 8.1 必须实现的抽象方法 +- `_create_communication_module()`: 创建通信模块 +- `_create_material_management_module()`: 创建物料管理模块 +- `_register_supported_workflows()`: 注册支持的工作流 + +### 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()`: 获取状态 + +## 9. 配置参数说明 + +```python +workstation_config = { + "communication_config": { + "protocol": "modbus_tcp", + "host": "192.168.1.100", + "port": 502 + }, + "deck_config": { + "size_x": 1000.0, + "size_y": 1000.0, + "size_z": 500.0 + }, + "http_service_config": { + "enabled": True, + "host": "127.0.0.1", + "port": 8081 + }, + "communication_interfaces": { + "logical_device_1": CommunicationInterface(...) + } +} +``` + +这个架构设计支持: +1. **灵活的通信方式**: 通过CommunicationBase支持PLC、串口、以太网等 +2. **多样的物料管理**: 支持PyLabRobot、Bioyond、简单物料系统 +3. **统一的HTTP报送**: 基于LIMS协议的标准化报送接口 +4. **完整的工作流控制**: 支持动态和静态工作流 +5. **强大的错误处理**: 多层次的错误处理和恢复机制 diff --git a/test/experiments/comprehensive_protocol/comprehensive_slim.json b/test/experiments/comprehensive_protocol/comprehensive_slim.json index bc028886..f533d22b 100644 --- a/test/experiments/comprehensive_protocol/comprehensive_slim.json +++ b/test/experiments/comprehensive_protocol/comprehensive_slim.json @@ -4,11 +4,12 @@ "id": "OrganicSynthesisStation", "name": "有机化学流程综合测试工作站", "children": [ - "heater_1" + "heater_1", + "deck" ], "parent": null, "type": "device", - "class": "workstation", + "class": "workstation.example", "position": { "x": 600, "y": 400, @@ -39,7 +40,13 @@ "DryProtocol", "HydrogenateProtocol", "RecrystallizeProtocol" - ] + ], + "station_resource": { + "data": { + "_resource_child_name": "deck", + "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck" + } + } }, "data": {} }, @@ -63,6 +70,1682 @@ "status": "Idle", "current_temp": 25.0 } + }, + { + "id": "deck", + "name": "deck", + "sample_id": null, + "children": [ + "tip_rack", + "plate_well" + ], + "parent": "OrganicSynthesisStation", + "type": "deck", + "class": "OTDeck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "OTDeck", + "with_trash": false, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + }, + { + "id": "tip_rack", + "name": "tip_rack", + "sample_id": null, + "children": [ + "tip_rack_A1", + "tip_rack_B1", + "tip_rack_C1", + "tip_rack_D1", + "tip_rack_E1", + "tip_rack_F1", + "tip_rack_G1", + "tip_rack_H1", + "tip_rack_A2", + "tip_rack_B2", + "tip_rack_C2", + "tip_rack_D2", + "tip_rack_E2", + "tip_rack_F2", + "tip_rack_G2", + "tip_rack_H2" + ], + "parent": "deck", + "type": "plate", + "class": "opentrons_96_filtertiprack_1000ul", + "position": { + "x": 0, + "y": 0, + "z": 69 + }, + "config": { + "type": "TipRack", + "size_x": 122.4, + "size_y": 82.6, + "size_z": 20.0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_rack", + "model": "HTF", + "ordering": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2" + ] + }, + "data": {} + }, + { + "id": "tip_rack_A1", + "name": "tip_rack_A1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 68.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_B1", + "name": "tip_rack_B1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 59.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_C1", + "name": "tip_rack_C1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 50.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_D1", + "name": "tip_rack_D1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 41.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_E1", + "name": "tip_rack_E1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 32.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_F1", + "name": "tip_rack_F1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 23.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_G1", + "name": "tip_rack_G1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 14.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_H1", + "name": "tip_rack_H1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 5.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_A2", + "name": "tip_rack_A2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 68.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_B2", + "name": "tip_rack_B2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 59.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_C2", + "name": "tip_rack_C2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 50.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_D2", + "name": "tip_rack_D2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 41.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_E2", + "name": "tip_rack_E2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 32.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_F2", + "name": "tip_rack_F2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 23.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_G2", + "name": "tip_rack_G2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 14.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_H2", + "name": "tip_rack_H2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 5.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "plate_well", + "name": "plate_well", + "sample_id": null, + "children": [ + "plate_well_A1", + "plate_well_B1", + "plate_well_C1", + "plate_well_D1", + "plate_well_E1", + "plate_well_F1", + "plate_well_G1", + "plate_well_H1", + "plate_well_A11", + "plate_well_B11", + "plate_well_C11", + "plate_well_D11", + "plate_well_E11", + "plate_well_F11", + "plate_well_G11", + "plate_well_H11" + ], + "parent": "deck", + "type": "plate", + "class": "nest_96_wellplate_2ml_deep", + "position": { + "x": 265.0, + "y": 0, + "z": 69 + }, + "config": { + "type": "Plate", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 14.2, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "plate", + "model": "Cor_96_wellplate_360ul_Fb", + "ordering": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ] + }, + "data": {} + }, + { + "id": "plate_well_A1", + "name": "plate_well_A1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 70.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_B1", + "name": "plate_well_B1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 61.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_C1", + "name": "plate_well_C1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 52.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_D1", + "name": "plate_well_D1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 43.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_E1", + "name": "plate_well_E1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 34.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_F1", + "name": "plate_well_F1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 25.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_G1", + "name": "plate_well_G1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 16.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_H1", + "name": "plate_well_H1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 7.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_A11", + "name": "plate_well_A11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 70.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_B11", + "name": "plate_well_B11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 61.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_C11", + "name": "plate_well_C11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 52.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_D11", + "name": "plate_well_D11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 43.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_E11", + "name": "plate_well_E11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 34.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_F11", + "name": "plate_well_F11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 25.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_G11", + "name": "plate_well_G11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 16.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_H11", + "name": "plate_well_H11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 7.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } } ], "links": [] diff --git a/unilabos/device_comms/coin_cell_assembly_workstation.py b/unilabos/device_comms/coin_cell_assembly_workstation.py new file mode 100644 index 00000000..62d9b09c --- /dev/null +++ b/unilabos/device_comms/coin_cell_assembly_workstation.py @@ -0,0 +1,454 @@ +""" +纽扣电池组装工作站 +Coin Cell Assembly Workstation + +继承工作站基类,实现纽扣电池特定功能 +""" +from typing import Dict, Any, List, Optional, Union + +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo +from unilabos.device_comms.workstation_communication import ( + WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication +) +from unilabos.device_comms.workstation_material_management import ( + MaterialManagementBase, CoinCellMaterialManagement +) +from unilabos.utils.log import logger + + +class CoinCellAssemblyWorkstation(WorkstationBase): + """纽扣电池组装工作站 + + 基于工作站基类,实现纽扣电池制造的特定功能: + 1. 纽扣电池特定的通信协议 + 2. 纽扣电池物料管理(料板、极片、电池等) + 3. 电池制造工作流 + 4. 质量检查工作流 + """ + + def __init__( + self, + device_id: str, + children: Dict[str, Dict[str, Any]], + protocol_type: Union[str, List[str]] = "BatteryManufacturingProtocol", + resource_tracker: Optional[DeviceNodeResourceTracker] = None, + modbus_config: Optional[Dict[str, Any]] = None, + deck_config: Optional[Dict[str, Any]] = None, + csv_path: str = "./coin_cell_assembly.csv", + *args, + **kwargs, + ): + # 设置通信配置 + modbus_config = modbus_config or {"host": "127.0.0.1", "port": 5021} + self.communication_config = CommunicationConfig( + protocol=CommunicationProtocol.MODBUS_TCP, + host=modbus_config["host"], + port=modbus_config["port"], + timeout=modbus_config.get("timeout", 5.0), + retry_count=modbus_config.get("retry_count", 3) + ) + + # 设置台面配置 + self.deck_config = deck_config or { + "size_x": 1620.0, + "size_y": 1270.0, + "size_z": 500.0 + } + + # CSV地址映射文件路径 + self.csv_path = csv_path + + # 创建资源跟踪器(如果没有提供) + if resource_tracker is None: + from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker + resource_tracker = DeviceNodeResourceTracker() + + # 初始化基类 + super().__init__( + device_id=device_id, + children=children, + protocol_type=protocol_type, + resource_tracker=resource_tracker, + communication_config=self.communication_config, + deck_config=self.deck_config, + *args, + **kwargs + ) + + logger.info(f"纽扣电池组装工作站 {device_id} 初始化完成") + + def _create_communication_module(self) -> WorkstationCommunicationBase: + """创建纽扣电池通信模块""" + return CoinCellCommunication( + communication_config=self.communication_config, + csv_path=self.csv_path + ) + + def _create_material_management_module(self) -> MaterialManagementBase: + """创建纽扣电池物料管理模块""" + return CoinCellMaterialManagement( + device_id=self.device_id, + deck_config=self.deck_config, + resource_tracker=self.resource_tracker, + children_config=self.children + ) + + def _register_supported_workflows(self): + """注册纽扣电池工作流""" + # 电池制造工作流 + self.supported_workflows["battery_manufacturing"] = WorkflowInfo( + name="battery_manufacturing", + description="纽扣电池制造工作流", + estimated_duration=300.0, # 5分钟 + required_materials=["cathode_sheet", "anode_sheet", "separator", "electrolyte"], + output_product="coin_cell_battery", + parameters_schema={ + "type": "object", + "properties": { + "electrolyte_num": { + "type": "integer", + "description": "电解液瓶数", + "minimum": 1, + "maximum": 32 + }, + "electrolyte_volume": { + "type": "number", + "description": "电解液体积 (μL)", + "minimum": 0.1, + "maximum": 100.0 + }, + "assembly_pressure": { + "type": "number", + "description": "组装压力 (N)", + "minimum": 100.0, + "maximum": 5000.0 + }, + "cathode_material": { + "type": "string", + "description": "正极材料类型", + "enum": ["LiFePO4", "LiCoO2", "NCM", "LMO"] + }, + "anode_material": { + "type": "string", + "description": "负极材料类型", + "enum": ["Graphite", "LTO", "Silicon"] + } + }, + "required": ["electrolyte_num", "electrolyte_volume", "assembly_pressure"] + } + ) + + # 质量检查工作流 + self.supported_workflows["quality_inspection"] = WorkflowInfo( + name="quality_inspection", + description="产品质量检查工作流", + estimated_duration=60.0, # 1分钟 + required_materials=["finished_battery"], + output_product="quality_report", + parameters_schema={ + "type": "object", + "properties": { + "test_voltage": { + "type": "boolean", + "description": "是否测试电压", + "default": True + }, + "test_capacity": { + "type": "boolean", + "description": "是否测试容量", + "default": False + }, + "voltage_threshold": { + "type": "number", + "description": "电压阈值 (V)", + "minimum": 2.0, + "maximum": 4.5, + "default": 3.0 + } + } + } + ) + + # 设备初始化工作流 + self.supported_workflows["device_initialization"] = WorkflowInfo( + name="device_initialization", + description="设备初始化工作流", + estimated_duration=30.0, # 30秒 + required_materials=[], + output_product="ready_status", + parameters_schema={ + "type": "object", + "properties": { + "auto_mode": { + "type": "boolean", + "description": "是否启用自动模式", + "default": True + } + } + } + ) + + # ============ 纽扣电池特定方法 ============ + + def get_electrode_sheet_inventory(self) -> Dict[str, int]: + """获取极片库存统计""" + try: + sheets = self.material_management.find_electrode_sheets() + inventory = {} + + for sheet in sheets: + material_type = getattr(sheet, 'material_type', 'unknown') + inventory[material_type] = inventory.get(material_type, 0) + 1 + + return inventory + + except Exception as e: + logger.error(f"获取极片库存失败: {e}") + return {} + + def get_battery_production_statistics(self) -> Dict[str, Any]: + """获取电池生产统计""" + try: + production_data = self.communication.get_production_data() + + # 添加物料统计 + electrode_inventory = self.get_electrode_sheet_inventory() + battery_count = len(self.material_management.find_batteries()) + + return { + **production_data, + "electrode_inventory": electrode_inventory, + "finished_battery_count": battery_count, + "material_plates": len(self.material_management.find_material_plates()), + "press_slots": len(self.material_management.find_press_slots()) + } + + except Exception as e: + logger.error(f"获取生产统计失败: {e}") + return {"error": str(e)} + + def create_new_battery(self, battery_spec: Dict[str, Any]) -> Optional[str]: + """创建新电池资源""" + try: + from unilabos.device_comms.button_battery_station import Battery + import uuid + + battery_id = f"battery_{uuid.uuid4().hex[:8]}" + + battery = Battery( + name=battery_id, + diameter=battery_spec.get("diameter", 20.0), + height=battery_spec.get("height", 3.2), + max_volume=battery_spec.get("max_volume", 100.0), + barcode=battery_spec.get("barcode", "") + ) + + # 添加到物料管理系统 + self.material_management.plr_resources[battery_id] = battery + self.material_management.resource_tracker.add_resource(battery) + + logger.info(f"创建新电池资源: {battery_id}") + return battery_id + + except Exception as e: + logger.error(f"创建电池资源失败: {e}") + return None + + def find_available_press_slot(self) -> Optional[str]: + """查找可用的压制槽""" + try: + press_slots = self.material_management.find_press_slots() + + for slot in press_slots: + if hasattr(slot, 'has_battery') and not slot.has_battery(): + return slot.name + + return None + + except Exception as e: + logger.error(f"查找可用压制槽失败: {e}") + return None + + def get_glove_box_environment(self) -> Dict[str, Any]: + """获取手套箱环境数据""" + try: + device_status = self.communication.get_device_status() + environment = device_status.get("environment", {}) + + return { + "pressure": environment.get("glove_box_pressure", 0.0), + "o2_content": environment.get("o2_content", 0.0), + "water_content": environment.get("water_content", 0.0), + "is_safe": ( + environment.get("o2_content", 0.0) < 10.0 and # 氧气含量 < 10ppm + environment.get("water_content", 0.0) < 1.0 # 水分含量 < 1ppm + ) + } + + except Exception as e: + logger.error(f"获取手套箱环境失败: {e}") + return {"error": str(e)} + + def start_data_export(self, file_path: str) -> bool: + """开始生产数据导出""" + try: + return self.communication.start_data_export(file_path, export_interval=5.0) + except Exception as e: + logger.error(f"启动数据导出失败: {e}") + return False + + def stop_data_export(self) -> bool: + """停止生产数据导出""" + try: + return self.communication.stop_data_export() + except Exception as e: + logger.error(f"停止数据导出失败: {e}") + return False + + # ============ 重写基类方法以支持纽扣电池特定功能 ============ + + def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: + """启动工作流(重写以支持纽扣电池特定预处理)""" + try: + # 进行纽扣电池特定的预检查 + if workflow_type == "battery_manufacturing": + # 检查手套箱环境 + env = self.get_glove_box_environment() + if not env.get("is_safe", False): + logger.error("手套箱环境不安全,无法启动电池制造工作流") + return False + + # 检查是否有可用的压制槽 + available_slot = self.find_available_press_slot() + if not available_slot: + logger.error("没有可用的压制槽,无法启动电池制造工作流") + return False + + # 检查极片库存 + electrode_inventory = self.get_electrode_sheet_inventory() + if not electrode_inventory.get("cathode", 0) > 0 or not electrode_inventory.get("anode", 0) > 0: + logger.error("极片库存不足,无法启动电池制造工作流") + return False + + # 调用基类方法 + return super().start_workflow(workflow_type, parameters) + + except Exception as e: + logger.error(f"启动纽扣电池工作流失败: {e}") + return False + + # ============ 纽扣电池特定状态属性 ============ + + @property + def electrode_sheet_count(self) -> int: + """极片总数""" + try: + return len(self.material_management.find_electrode_sheets()) + except: + return 0 + + @property + def battery_count(self) -> int: + """电池总数""" + try: + return len(self.material_management.find_batteries()) + except: + return 0 + + @property + def available_press_slots(self) -> int: + """可用压制槽数""" + try: + press_slots = self.material_management.find_press_slots() + available = 0 + for slot in press_slots: + if hasattr(slot, 'has_battery') and not slot.has_battery(): + available += 1 + return available + except: + return 0 + + @property + def environment_status(self) -> Dict[str, Any]: + """环境状态""" + return self.get_glove_box_environment() + + +# ============ 工厂函数 ============ + +def create_coin_cell_workstation( + device_id: str, + config_file: str, + modbus_host: str = "127.0.0.1", + modbus_port: int = 5021, + csv_path: str = "./coin_cell_assembly.csv" +) -> CoinCellAssemblyWorkstation: + """工厂函数:创建纽扣电池组装工作站 + + Args: + device_id: 设备ID + config_file: 配置文件路径(JSON格式) + modbus_host: Modbus主机地址 + modbus_port: Modbus端口 + csv_path: 地址映射CSV文件路径 + + Returns: + CoinCellAssemblyWorkstation: 工作站实例 + """ + import json + + try: + # 加载配置文件 + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + # 提取配置 + children = config.get("children", {}) + deck_config = config.get("deck_config", {}) + + # 创建工作站 + workstation = CoinCellAssemblyWorkstation( + device_id=device_id, + children=children, + modbus_config={ + "host": modbus_host, + "port": modbus_port + }, + deck_config=deck_config, + csv_path=csv_path + ) + + logger.info(f"纽扣电池工作站创建成功: {device_id}") + return workstation + + except Exception as e: + logger.error(f"创建纽扣电池工作站失败: {e}") + raise + + +if __name__ == "__main__": + # 示例用法 + workstation = create_coin_cell_workstation( + device_id="coin_cell_station_01", + config_file="./button_battery_workstation.json", + modbus_host="127.0.0.1", + modbus_port=5021 + ) + + # 启动电池制造工作流 + success = workstation.start_workflow( + "battery_manufacturing", + { + "electrolyte_num": 16, + "electrolyte_volume": 50.0, + "assembly_pressure": 2000.0, + "cathode_material": "LiFePO4", + "anode_material": "Graphite" + } + ) + + if success: + print("电池制造工作流启动成功") + else: + print("电池制造工作流启动失败") diff --git a/unilabos/device_comms/modbus_plc/client.py b/unilabos/device_comms/modbus_plc/client.py index a7da3aff..c239b9d5 100644 --- a/unilabos/device_comms/modbus_plc/client.py +++ b/unilabos/device_comms/modbus_plc/client.py @@ -8,8 +8,8 @@ from pymodbus.client import ModbusSerialClient, ModbusTcpClient from pymodbus.framer import FramerType from typing import TypedDict -from unilabos.device_comms.modbus_plc.node.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder -from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase +from unilabos.device_comms.modbus_plc.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder +from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase from unilabos.device_comms.universal_driver import UniversalDriver from unilabos.utils.log import logger import pandas as pd diff --git a/unilabos/device_comms/modbus_plc/node/modbus.py b/unilabos/device_comms/modbus_plc/modbus.py similarity index 100% rename from unilabos/device_comms/modbus_plc/node/modbus.py rename to unilabos/device_comms/modbus_plc/modbus.py diff --git a/unilabos/device_comms/modbus_plc/test/client.py b/unilabos/device_comms/modbus_plc/test/client.py index 070e180b..416f8493 100644 --- a/unilabos/device_comms/modbus_plc/test/client.py +++ b/unilabos/device_comms/modbus_plc/test/client.py @@ -1,6 +1,6 @@ import time from pymodbus.client import ModbusTcpClient -from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister +from unilabos.device_comms.modbus_plc.modbus import Coil, HoldRegister from pymodbus.payload import BinaryPayloadDecoder from pymodbus.constants import Endian diff --git a/unilabos/device_comms/modbus_plc/test/node_test.py b/unilabos/device_comms/modbus_plc/test/node_test.py index d2fa2d75..d36f28d7 100644 --- a/unilabos/device_comms/modbus_plc/test/node_test.py +++ b/unilabos/device_comms/modbus_plc/test/node_test.py @@ -1,6 +1,6 @@ # coding=utf-8 from pymodbus.client import ModbusTcpClient -from unilabos.device_comms.modbus_plc.node.modbus import Coil +from unilabos.device_comms.modbus_plc.modbus import Coil import time diff --git a/unilabos/device_comms/modbus_plc/test/test_workflow.py b/unilabos/device_comms/modbus_plc/test/test_workflow.py index e418a3c5..8f764d6a 100644 --- a/unilabos/device_comms/modbus_plc/test/test_workflow.py +++ b/unilabos/device_comms/modbus_plc/test/test_workflow.py @@ -1,7 +1,7 @@ import time from typing import Callable from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusWorkflow, WorkflowAction, load_csv -from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase +from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase ############ 第一种写法 ############## diff --git a/unilabos/devices/workstation/README.md b/unilabos/devices/workstation/README.md new file mode 100644 index 00000000..710a2211 --- /dev/null +++ b/unilabos/devices/workstation/README.md @@ -0,0 +1,184 @@ +# 工作站抽象基类物料系统架构说明 + +## 设计理念 + +基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。 + +### 核心原则 + +1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系 +2. **Graphio转换函数集成**:使用graphio中的`resource_ulab_to_plr`等转换函数实现UniLab与PLR格式的无缝转换 +3. **关注点分离**:基类专注核心物料系统,HTTP服务等功能在子类中实现 +4. **外部系统集成模式**:通过ResourceSynchronizer抽象类提供外部物料系统对接模式 + +## 架构组成 + +### 1. WorkstationBase(基类) +**文件**: `workstation_base.py` + +**核心功能**: +- 使用deck_config和children通过`resource_ulab_to_plr`转换为PLR物料self.deck +- 基础的资源查找和管理功能 +- 抽象的工作流执行接口 +- ResourceSynchronizer集成点 + +**关键代码**: +```python +def _initialize_material_system(self, deck_config: Dict[str, Any], children_config: Dict[str, Any] = None): + """初始化基于PLR的物料系统""" + # 合并deck_config和children + complete_config = self._merge_deck_and_children_config(deck_config, children_config) + + # 使用graphio转换函数转换为PLR资源 + self.deck = resource_ulab_to_plr(complete_config) +``` + +### 2. ResourceSynchronizer(外部系统集成抽象类) +**定义在**: `workstation_base.py` + +**设计目的**: +- 提供外部物料系统(如Bioyong、LIMS等)集成的标准接口 +- 双向同步:从外部系统同步到本地deck,以及将本地变更同步到外部系统 +- 处理外部系统的变更通知 + +**核心方法**: +```python +async def sync_from_external(self) -> bool: + """从外部系统同步物料到本地deck""" + +async def sync_to_external(self, plr_resource) -> bool: + """将本地物料同步到外部系统""" + +async def handle_external_change(self, change_info: Dict[str, Any]) -> bool: + """处理外部系统的变更通知""" +``` + +### 3. WorkstationWithHTTP(子类示例) +**文件**: `workstation_with_http_example.py` + +**扩展功能**: +- HTTP报送接收服务集成 +- 具体工作流实现(液体转移、板洗等) +- Bioyong物料系统同步器示例 +- 外部报送处理方法 + +## 技术栈 + +### 核心依赖 +- **PyLabRobot**: 物料资源管理核心(Deck, Resource, Coordinate) +- **GraphIO转换函数**: UniLab ↔ PLR格式转换 + - `resource_ulab_to_plr`: UniLab格式转PLR格式 + - `resource_plr_to_ulab`: PLR格式转UniLab格式 + - `convert_resources_to_type`: 通用资源类型转换 +- **ROS2**: 基础设备节点通信(BaseROS2DeviceNode) + +### 可选依赖 +- **HTTP服务**: 仅在需要外部报送接收的子类中使用 +- **外部系统API**: 根据具体集成需求添加 + +## 使用示例 + +### 1. 简单工作站(仅PLR物料系统) + +```python +from unilabos.devices.workstation.workstation_base import WorkstationBase + +# Deck配置 +deck_config = { + "size_x": 1200.0, + "size_y": 800.0, + "size_z": 100.0 +} + +# 子资源配置 +children_config = { + "source_plate": { + "name": "source_plate", + "type": "plate", + "position": {"x": 100, "y": 100, "z": 10}, + "config": {"size_x": 127.8, "size_y": 85.5, "size_z": 14.4} + } +} + +# 创建工作站 +workstation = WorkstationBase( + device_id="simple_workstation", + deck_config=deck_config, + children_config=children_config +) + +# 查找资源 +plate = workstation.find_resource_by_name("source_plate") +``` + +### 2. 带HTTP服务的工作站 + +```python +from unilabos.devices.workstation.workstation_with_http_example import WorkstationWithHTTP + +# HTTP服务配置 +http_service_config = { + "enabled": True, + "host": "127.0.0.1", + "port": 8081 +} + +# 创建带HTTP服务的工作站 +workstation = WorkstationWithHTTP( + device_id="http_workstation", + deck_config=deck_config, + children_config=children_config, + http_service_config=http_service_config +) + +# 执行工作流 +success = workstation.execute_workflow("liquid_transfer", { + "volume": 100.0, + "source_wells": ["A1", "A2"], + "dest_wells": ["B1", "B2"] +}) +``` + +### 3. 外部系统集成 + +```python +class BioyongResourceSynchronizer(ResourceSynchronizer): + """Bioyong系统同步器""" + + async def sync_from_external(self) -> bool: + # 从Bioyong API获取物料 + external_materials = await self._fetch_bioyong_materials() + + # 转换并添加到本地deck + for material in external_materials: + await self._add_material_to_deck(material) + + return True +``` + +## 设计优势 + +### 1. **简洁性** +- 基类只专注核心物料管理,没有冗余功能 +- 使用成熟的PyLabRobot作为物料管理基础 + +### 2. **可扩展性** +- 通过子类添加HTTP服务、特定工作流等功能 +- ResourceSynchronizer模式支持任意外部系统集成 + +### 3. **标准化** +- PLR Deck提供标准的资源管理接口 +- Graphio转换函数确保格式一致性 + +### 4. **灵活性** +- 可选择性使用HTTP服务和外部系统集成 +- 支持不同类型的工作站需求 + +## 发展历程 + +1. **初始设计**: 复杂的统一物料系统,包含HTTP服务和多种功能 +2. **PyLabRobot集成**: 引入PLR Deck管理,但保留了ResourceTracker复杂性 +3. **Graphio转换**: 使用graphio转换函数简化初始化 +4. **最终简化**: 专注核心PLR物料系统,HTTP服务移至子类 + +这个架构体现了"用PyLabRobot Deck来管理物料会更好;但是要做好和外部物料系统的对接"的设计理念,以及"现在我只需要在工作站创建的时候,整体使用deck_config和children,一起通过resource_ulab_to_plr转换为plr物料self.deck即可"的简化要求。 diff --git a/unilabos/device_comms/modbus_plc/node/__init__.py b/unilabos/devices/workstation/__init__.py similarity index 100% rename from unilabos/device_comms/modbus_plc/node/__init__.py rename to unilabos/devices/workstation/__init__.py diff --git a/unilabos/devices/workstation/coin_cell_assembly/__init__.py b/unilabos/devices/workstation/coin_cell_assembly/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py new file mode 100644 index 00000000..ee88e602 --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -0,0 +1,39 @@ +from typing import Any, Dict, Optional +from pylabrobot.resources import Resource as PLRResource +from unilabos.device_comms.modbus_plc.client import ModbusTcpClient +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer, WorkstationBase + + +class CoinCellAssemblyWorkstation(WorkstationBase): + def __init__( + self, + device_id: str, + deck_config: Dict[str, Any], + children: Optional[Dict[str, Any]] = None, + resource_synchronizer: Optional[ResourceSynchronizer] = None, + host: str = "192.168.0.0", + port: str = "", + *args, + **kwargs, + ): + super().__init__( + device_id=device_id, + deck_config=deck_config, + children=children, + resource_synchronizer=resource_synchronizer, + *args, + **kwargs, + ) + + self.hardware_interface = ModbusTcpClient(host=host, port=port) + + def run_assembly(self, wf_name: str, resource: PLRResource, params: str = "\{\}"): + """启动工作流""" + self.current_workflow_status = WorkflowStatus.RUNNING + logger.info(f"工作站 {self.device_id} 启动工作流: {wf_name}") + + # TODO: 实现工作流逻辑 + + anode_sheet = self.deck.get_resource("anode_sheet") + + \ No newline at end of file diff --git a/unilabos/devices/workstation/workflow_executors.py b/unilabos/devices/workstation/workflow_executors.py new file mode 100644 index 00000000..41a51c14 --- /dev/null +++ b/unilabos/devices/workstation/workflow_executors.py @@ -0,0 +1,649 @@ +""" +工作流执行器模块 +Workflow Executors Module + +基于单一硬件接口的工作流执行器实现 +支持Modbus、HTTP、PyLabRobot和代理模式 +""" +import time +import json +import asyncio +from typing import Dict, Any, List, Optional, TYPE_CHECKING +from abc import ABC, abstractmethod + +if TYPE_CHECKING: + from unilabos.devices.work_station.workstation_base import WorkstationBase + +from unilabos.utils.log import logger + + +class WorkflowExecutor(ABC): + """工作流执行器基类 - 基于单一硬件接口""" + + def __init__(self, workstation: 'WorkstationBase'): + self.workstation = workstation + self.hardware_interface = workstation.hardware_interface + self.material_management = workstation.material_management + + @abstractmethod + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流""" + pass + + @abstractmethod + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + pass + + def call_device(self, method: str, *args, **kwargs) -> Any: + """调用设备方法的统一接口""" + return self.workstation.call_device_method(method, *args, **kwargs) + + def get_device_status(self) -> Dict[str, Any]: + """获取设备状态""" + return self.workstation.get_device_status() + + +class ModbusWorkflowExecutor(WorkflowExecutor): + """Modbus工作流执行器 - 适配 coin_cell_assembly_system""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证Modbus接口 + if not (hasattr(self.hardware_interface, 'write_register') and + hasattr(self.hardware_interface, 'read_register')): + raise RuntimeError("工作站硬件接口不是有效的Modbus客户端") + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行Modbus工作流""" + if workflow_name == "battery_manufacturing": + return self._execute_battery_manufacturing(parameters) + elif workflow_name == "material_loading": + return self._execute_material_loading(parameters) + elif workflow_name == "quality_check": + return self._execute_quality_check(parameters) + else: + logger.warning(f"不支持的Modbus工作流: {workflow_name}") + return False + + def _execute_battery_manufacturing(self, parameters: Dict[str, Any]) -> bool: + """执行电池制造工作流""" + try: + # 1. 物料准备检查 + available_slot = self._find_available_press_slot() + if not available_slot: + raise RuntimeError("没有可用的压制槽") + + logger.info(f"找到可用压制槽: {available_slot}") + + # 2. 设置工艺参数(直接调用Modbus接口) + if "electrolyte_num" in parameters: + self.hardware_interface.write_register('REG_MSG_ELECTROLYTE_NUM', parameters["electrolyte_num"]) + logger.info(f"设置电解液编号: {parameters['electrolyte_num']}") + + if "electrolyte_volume" in parameters: + self.hardware_interface.write_register('REG_MSG_ELECTROLYTE_VOLUME', + parameters["electrolyte_volume"], + data_type="FLOAT32") + logger.info(f"设置电解液体积: {parameters['electrolyte_volume']}") + + if "assembly_pressure" in parameters: + self.hardware_interface.write_register('REG_MSG_ASSEMBLY_PRESSURE', + parameters["assembly_pressure"], + data_type="FLOAT32") + logger.info(f"设置装配压力: {parameters['assembly_pressure']}") + + # 3. 启动制造流程 + self.hardware_interface.write_register('COIL_SYS_START_CMD', True) + logger.info("启动电池制造流程") + + # 4. 确认启动成功 + time.sleep(0.5) + status = self.hardware_interface.read_register('COIL_SYS_START_STATUS', count=1) + success = status[0] if status else False + + if success: + logger.info(f"电池制造工作流启动成功,参数: {parameters}") + else: + logger.error("电池制造工作流启动失败") + + return success + + except Exception as e: + logger.error(f"执行电池制造工作流失败: {e}") + return False + + def _execute_material_loading(self, parameters: Dict[str, Any]) -> bool: + """执行物料装载工作流""" + try: + material_type = parameters.get('material_type', 'cathode') + position = parameters.get('position', 'A1') + + logger.info(f"开始物料装载: {material_type} -> {position}") + + # 设置物料类型和位置 + self.hardware_interface.write_register('REG_MATERIAL_TYPE', material_type) + self.hardware_interface.write_register('REG_MATERIAL_POSITION', position) + + # 启动装载 + self.hardware_interface.write_register('COIL_LOAD_START', True) + + # 等待装载完成 + timeout = parameters.get('timeout', 30) + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.hardware_interface.read_register('COIL_LOAD_COMPLETE', count=1) + if status and status[0]: + logger.info(f"物料装载完成: {material_type} -> {position}") + return True + time.sleep(0.5) + + logger.error(f"物料装载超时: {material_type} -> {position}") + return False + + except Exception as e: + logger.error(f"执行物料装载失败: {e}") + return False + + def _execute_quality_check(self, parameters: Dict[str, Any]) -> bool: + """执行质量检测工作流""" + try: + check_type = parameters.get('check_type', 'dimensional') + + logger.info(f"开始质量检测: {check_type}") + + # 启动质量检测 + self.hardware_interface.write_register('REG_QC_TYPE', check_type) + self.hardware_interface.write_register('COIL_QC_START', True) + + # 等待检测完成 + timeout = parameters.get('timeout', 60) + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.hardware_interface.read_register('COIL_QC_COMPLETE', count=1) + if status and status[0]: + # 读取检测结果 + result = self.hardware_interface.read_register('REG_QC_RESULT', count=1) + passed = result[0] if result else False + + if passed: + logger.info(f"质量检测通过: {check_type}") + return True + else: + logger.warning(f"质量检测失败: {check_type}") + return False + + time.sleep(1.0) + + logger.error(f"质量检测超时: {check_type}") + return False + + except Exception as e: + logger.error(f"执行质量检测失败: {e}") + return False + + def _find_available_press_slot(self) -> Optional[str]: + """查找可用压制槽""" + try: + press_slots = self.material_management.find_by_category("battery_press_slot") + for slot in press_slots: + if hasattr(slot, 'has_battery') and not slot.has_battery(): + return slot.name + return None + except: + # 如果物料管理系统不可用,返回默认槽位 + return "A1" + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if emergency: + self.hardware_interface.write_register('COIL_SYS_RESET_CMD', True) + logger.warning("执行紧急停止") + else: + self.hardware_interface.write_register('COIL_SYS_STOP_CMD', True) + logger.info("执行正常停止") + + time.sleep(0.5) + status = self.hardware_interface.read_register('COIL_SYS_STOP_STATUS', count=1) + return status[0] if status else False + + except Exception as e: + logger.error(f"停止Modbus工作流失败: {e}") + return False + + +class HttpWorkflowExecutor(WorkflowExecutor): + """HTTP工作流执行器 - 适配 reaction_station_bioyong""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证HTTP接口 + if not (hasattr(self.hardware_interface, 'post') or + hasattr(self.hardware_interface, 'get')): + raise RuntimeError("工作站硬件接口不是有效的HTTP客户端") + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行HTTP工作流""" + try: + if workflow_name == "reaction_synthesis": + return self._execute_reaction_synthesis(parameters) + elif workflow_name == "liquid_feeding": + return self._execute_liquid_feeding(parameters) + elif workflow_name == "temperature_control": + return self._execute_temperature_control(parameters) + else: + logger.warning(f"不支持的HTTP工作流: {workflow_name}") + return False + + except Exception as e: + logger.error(f"执行HTTP工作流失败: {e}") + return False + + def _execute_reaction_synthesis(self, parameters: Dict[str, Any]) -> bool: + """执行反应合成工作流""" + try: + # 1. 设置工作流序列 + sequence = self._build_reaction_sequence(parameters) + self._call_rpc_method('set_workflow_sequence', json.dumps(sequence)) + + # 2. 设置反应参数 + if parameters.get('temperature'): + self._call_rpc_method('set_temperature', parameters['temperature']) + + if parameters.get('pressure'): + self._call_rpc_method('set_pressure', parameters['pressure']) + + if parameters.get('stirring_speed'): + self._call_rpc_method('set_stirring_speed', parameters['stirring_speed']) + + # 3. 执行工作流 + result = self._call_rpc_method('execute_current_sequence', { + "task_name": "reaction_synthesis" + }) + + success = result.get('success', False) + if success: + logger.info("反应合成工作流执行成功") + else: + logger.error(f"反应合成工作流执行失败: {result.get('error', '未知错误')}") + + return success + + except Exception as e: + logger.error(f"执行反应合成工作流失败: {e}") + return False + + def _execute_liquid_feeding(self, parameters: Dict[str, Any]) -> bool: + """执行液体投料工作流""" + try: + reagents = parameters.get('reagents', []) + volumes = parameters.get('volumes', []) + + if len(reagents) != len(volumes): + raise ValueError("试剂列表和体积列表长度不匹配") + + # 执行投料序列 + for reagent, volume in zip(reagents, volumes): + result = self._call_rpc_method('feed_liquid', { + 'reagent': reagent, + 'volume': volume + }) + + if not result.get('success', False): + logger.error(f"投料失败: {reagent} {volume}mL") + return False + + logger.info(f"投料成功: {reagent} {volume}mL") + + return True + + except Exception as e: + logger.error(f"执行液体投料失败: {e}") + return False + + def _execute_temperature_control(self, parameters: Dict[str, Any]) -> bool: + """执行温度控制工作流""" + try: + target_temp = parameters.get('temperature', 25) + hold_time = parameters.get('hold_time', 300) # 秒 + + # 设置目标温度 + result = self._call_rpc_method('set_temperature', target_temp) + if not result.get('success', False): + logger.error(f"设置温度失败: {target_temp}°C") + return False + + # 等待温度稳定 + logger.info(f"等待温度稳定到 {target_temp}°C") + + # 保持温度指定时间 + if hold_time > 0: + logger.info(f"保持温度 {hold_time} 秒") + time.sleep(hold_time) + + return True + + except Exception as e: + logger.error(f"执行温度控制失败: {e}") + return False + + def _build_reaction_sequence(self, parameters: Dict[str, Any]) -> List[str]: + """构建反应合成工作流序列""" + sequence = [] + + # 添加预处理步骤 + if parameters.get('purge_with_inert'): + sequence.append("purge_inert_gas") + + # 添加温度设置 + if parameters.get('temperature'): + sequence.append(f"set_temperature_{parameters['temperature']}") + + # 添加压力设置 + if parameters.get('pressure'): + sequence.append(f"set_pressure_{parameters['pressure']}") + + # 添加搅拌设置 + if parameters.get('stirring_speed'): + sequence.append(f"set_stirring_{parameters['stirring_speed']}") + + # 添加反应步骤 + sequence.extend([ + "start_reaction", + "monitor_progress", + "complete_reaction" + ]) + + # 添加后处理步骤 + if parameters.get('cooling_required'): + sequence.append("cool_down") + + return sequence + + def _call_rpc_method(self, method: str, params: Any = None) -> Dict[str, Any]: + """调用RPC方法""" + try: + if hasattr(self.hardware_interface, method): + # 直接方法调用 + if isinstance(params, dict): + params = json.dumps(params) + elif params is None: + params = "" + return getattr(self.hardware_interface, method)(params) + else: + # HTTP请求调用 + if hasattr(self.hardware_interface, 'post'): + response = self.hardware_interface.post(f"/api/{method}", json=params) + return response.json() + else: + raise AttributeError(f"HTTP接口不支持方法: {method}") + except Exception as e: + logger.error(f"调用RPC方法失败 {method}: {e}") + return {'success': False, 'error': str(e)} + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if emergency: + result = self._call_rpc_method('scheduler_reset') + else: + result = self._call_rpc_method('scheduler_stop') + + return result.get('success', False) + + except Exception as e: + logger.error(f"停止HTTP工作流失败: {e}") + return False + + +class PyLabRobotWorkflowExecutor(WorkflowExecutor): + """PyLabRobot工作流执行器 - 适配 prcxi.py""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证PyLabRobot接口 + if not (hasattr(self.hardware_interface, 'transfer_liquid') or + hasattr(self.hardware_interface, 'pickup_tips')): + raise RuntimeError("工作站硬件接口不是有效的PyLabRobot设备") + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行PyLabRobot工作流""" + try: + if workflow_name == "liquid_transfer": + return self._execute_liquid_transfer(parameters) + elif workflow_name == "tip_pickup_drop": + return self._execute_tip_operations(parameters) + elif workflow_name == "plate_handling": + return self._execute_plate_handling(parameters) + else: + logger.warning(f"不支持的PyLabRobot工作流: {workflow_name}") + return False + + except Exception as e: + logger.error(f"执行PyLabRobot工作流失败: {e}") + return False + + def _execute_liquid_transfer(self, parameters: Dict[str, Any]) -> bool: + """执行液体转移工作流""" + try: + # 1. 解析物料引用 + sources = self._resolve_containers(parameters.get('sources', [])) + targets = self._resolve_containers(parameters.get('targets', [])) + tip_racks = self._resolve_tip_racks(parameters.get('tip_racks', [])) + + if not sources or not targets: + raise ValueError("液体转移需要指定源容器和目标容器") + + if not tip_racks: + logger.warning("未指定枪头架,将尝试自动查找") + tip_racks = self._find_available_tip_racks() + + # 2. 执行液体转移 + volumes = parameters.get('volumes', []) + if not volumes: + volumes = [100.0] * len(sources) # 默认体积 + + # 如果是同步接口 + if hasattr(self.hardware_interface, 'transfer_liquid'): + result = self.hardware_interface.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=tip_racks, + asp_vols=volumes, + dis_vols=volumes, + **parameters.get('options', {}) + ) + else: + # 异步接口需要特殊处理 + asyncio.run(self._async_liquid_transfer(sources, targets, tip_racks, volumes, parameters)) + result = True + + if result: + logger.info(f"液体转移工作流完成: {len(sources)}个源 -> {len(targets)}个目标") + + return bool(result) + + except Exception as e: + logger.error(f"执行液体转移失败: {e}") + return False + + async def _async_liquid_transfer(self, sources, targets, tip_racks, volumes, parameters): + """异步液体转移""" + await self.hardware_interface.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=tip_racks, + asp_vols=volumes, + dis_vols=volumes, + **parameters.get('options', {}) + ) + + def _execute_tip_operations(self, parameters: Dict[str, Any]) -> bool: + """执行枪头操作工作流""" + try: + operation = parameters.get('operation', 'pickup') + tip_racks = self._resolve_tip_racks(parameters.get('tip_racks', [])) + + if not tip_racks: + raise ValueError("枪头操作需要指定枪头架") + + if operation == 'pickup': + result = self.hardware_interface.pickup_tips(tip_racks[0]) + logger.info("枪头拾取完成") + elif operation == 'drop': + result = self.hardware_interface.drop_tips() + logger.info("枪头丢弃完成") + else: + raise ValueError(f"不支持的枪头操作: {operation}") + + return bool(result) + + except Exception as e: + logger.error(f"执行枪头操作失败: {e}") + return False + + def _execute_plate_handling(self, parameters: Dict[str, Any]) -> bool: + """执行板类处理工作流""" + try: + operation = parameters.get('operation', 'move') + source_position = parameters.get('source_position') + target_position = parameters.get('target_position') + + if operation == 'move' and source_position and target_position: + # 移动板类 + result = self.hardware_interface.move_plate(source_position, target_position) + logger.info(f"板类移动完成: {source_position} -> {target_position}") + else: + logger.warning(f"不支持的板类操作或参数不完整: {operation}") + return False + + return bool(result) + + except Exception as e: + logger.error(f"执行板类处理失败: {e}") + return False + + def _resolve_containers(self, container_names: List[str]): + """解析容器名称为实际容器对象""" + containers = [] + for name in container_names: + try: + container = self.material_management.find_material_by_id(name) + if container: + containers.append(container) + else: + logger.warning(f"未找到容器: {name}") + except: + logger.warning(f"解析容器失败: {name}") + return containers + + def _resolve_tip_racks(self, tip_rack_names: List[str]): + """解析枪头架名称为实际对象""" + tip_racks = [] + for name in tip_rack_names: + try: + tip_rack = self.material_management.find_by_category("tip_rack") + matching_racks = [rack for rack in tip_rack if rack.name == name] + if matching_racks: + tip_racks.extend(matching_racks) + else: + logger.warning(f"未找到枪头架: {name}") + except: + logger.warning(f"解析枪头架失败: {name}") + return tip_racks + + def _find_available_tip_racks(self): + """查找可用的枪头架""" + try: + tip_racks = self.material_management.find_by_category("tip_rack") + available_racks = [rack for rack in tip_racks if hasattr(rack, 'has_tips') and rack.has_tips()] + return available_racks[:1] # 返回第一个可用的枪头架 + except: + return [] + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if emergency: + if hasattr(self.hardware_interface, 'emergency_stop'): + return self.hardware_interface.emergency_stop() + else: + logger.warning("设备不支持紧急停止") + return False + else: + if hasattr(self.hardware_interface, 'graceful_stop'): + return self.hardware_interface.graceful_stop() + elif hasattr(self.hardware_interface, 'stop'): + return self.hardware_interface.stop() + else: + logger.warning("设备不支持优雅停止") + return False + + except Exception as e: + logger.error(f"停止PyLabRobot工作流失败: {e}") + return False + + +class ProxyWorkflowExecutor(WorkflowExecutor): + """代理工作流执行器 - 处理代理模式的工作流""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证代理接口 + if not isinstance(self.hardware_interface, str) or not self.hardware_interface.startswith("proxy:"): + raise RuntimeError("工作站硬件接口不是有效的代理字符串") + + self.device_id = self.hardware_interface[6:] # 移除 "proxy:" 前缀 + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行代理工作流""" + try: + # 通过协议节点调用目标设备的工作流 + if self.workstation._workstation_node: + return self.workstation._workstation_node.call_device_method( + self.device_id, 'execute_workflow', workflow_name, parameters + ) + else: + logger.error("代理模式需要workstation_node") + return False + + except Exception as e: + logger.error(f"执行代理工作流失败: {e}") + return False + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止代理工作流""" + try: + if self.workstation._workstation_node: + return self.workstation._workstation_node.call_device_method( + self.device_id, 'stop_workflow', emergency + ) + else: + logger.error("代理模式需要workstation_node") + return False + + except Exception as e: + logger.error(f"停止代理工作流失败: {e}") + return False + + +# 辅助函数 +def get_executor_for_interface(hardware_interface) -> str: + """根据硬件接口类型获取执行器类型名称""" + if isinstance(hardware_interface, str) and hardware_interface.startswith("proxy:"): + return "ProxyWorkflowExecutor" + elif hasattr(hardware_interface, 'write_register') and hasattr(hardware_interface, 'read_register'): + return "ModbusWorkflowExecutor" + elif hasattr(hardware_interface, 'post') or hasattr(hardware_interface, 'get'): + return "HttpWorkflowExecutor" + elif hasattr(hardware_interface, 'transfer_liquid') or hasattr(hardware_interface, 'pickup_tips'): + return "PyLabRobotWorkflowExecutor" + else: + return "UnknownExecutor" diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py new file mode 100644 index 00000000..5337f6da --- /dev/null +++ b/unilabos/devices/workstation/workstation_base.py @@ -0,0 +1,489 @@ +""" +工作站基类 +Workstation Base Class - 简化版 + +基于PLR Deck的简化工作站架构 +专注于核心物料系统和工作流管理 +""" + +import collections +import time +from typing import Dict, Any, List, Optional, Union +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from pylabrobot.resources import Deck, Plate, Resource as PLRResource + +from pylabrobot.resources.coordinate import Coordinate +from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode + +from unilabos.utils.log import logger + + +class WorkflowStatus(Enum): + """工作流状态""" + + IDLE = "idle" + INITIALIZING = "initializing" + RUNNING = "running" + PAUSED = "paused" + STOPPING = "stopping" + STOPPED = "stopped" + ERROR = "error" + COMPLETED = "completed" + + +@dataclass +class WorkflowInfo: + """工作流信息""" + + name: str + description: str + estimated_duration: float # 预估持续时间(秒) + required_materials: List[str] # 所需物料类型 + output_product: str # 输出产品类型 + parameters_schema: Dict[str, Any] # 参数架构 + + +class WorkStationContainer(Plate): + """ + WorkStation 专用 Container 类,继承自 Plate和TipRack + 注意这个物料必须通过plr_additional_res_reg.py注册到edge,才能正常序列化 + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str, + ordering: collections.OrderedDict, + model: Optional[str] = None, + ): + """ + 这里的初始化入参要和plr的保持一致 + """ + super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) + self._unilabos_state = {} # 必须有此行,自己的类描述的是物料的 + + def load_state(self, state: Dict[str, Any]) -> None: + """从给定的状态加载工作台信息。""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + data = super().serialize_state() + data.update( + self._unilabos_state + ) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + return data + + +def get_workstation_plate_resource(name: str) -> PLRResource: # 要给定一个返回plr的方法 + """ + 用于获取一些模板,例如返回一个带有特定信息/子物料的 Plate,这里需要到注册表注册,例如unilabos/registry/resources/organic/workstation.yaml + 可以直接运行该函数或者利用注册表补全机制,来检查是否资源出错 + :param name: 资源名称 + :return: Resource对象 + """ + plate = WorkStationContainer( + name, size_x=50, size_y=50, size_z=10, category="plate", ordering=collections.OrderedDict() + ) + tip_rack = WorkStationContainer( + "tip_rack_inside_plate", + size_x=50, + size_y=50, + size_z=10, + category="tip_rack", + ordering=collections.OrderedDict(), + ) + plate.assign_child_resource(tip_rack, Coordinate.zero()) + return plate + + +class ResourceSynchronizer(ABC): + """资源同步器基类 + + 负责与外部物料系统的同步,并对 self.deck 做修改 + """ + + def __init__(self, workstation: "WorkstationBase"): + self.workstation = workstation + + @abstractmethod + async def sync_from_external(self) -> bool: + """从外部系统同步物料到本地deck""" + pass + + @abstractmethod + async def sync_to_external(self, plr_resource: PLRResource) -> bool: + """将本地物料同步到外部系统""" + pass + + @abstractmethod + async def handle_external_change(self, change_info: Dict[str, Any]) -> bool: + """处理外部系统的变更通知""" + pass + + +class WorkstationBase(ABC): + """工作站基类 - 简化版 + + 核心功能: + 1. 基于 PLR Deck 的物料系统,支持格式转换 + 2. 可选的资源同步器支持外部物料系统 + 3. 简化的工作流管理 + """ + + _ros_node: ROS2WorkstationNode + + @property + def _children(self) -> Dict[str, Any]: # 不要删除这个下划线,不然会自动导入注册表,后面改成装饰器识别 + return self._ros_node.children + + async def update_resource_example(self): + return await self._ros_node.update_resource([get_workstation_plate_resource("test")]) + + def __init__( + self, + station_resource: PLRResource, + *args, + **kwargs, # 必须有kwargs + ): + # 基本配置 + print(station_resource) + self.deck_config = station_resource + + # PLR 物料系统 + self.deck: Optional[Deck] = None + self.plr_resources: Dict[str, PLRResource] = {} + + # 资源同步器(可选) + self.resource_synchronizer = ResourceSynchronizer(self) # 要在driver中自行初始化,只有workstation用 + + # 硬件接口 + self.hardware_interface: Union[Any, str] = None + + # 工作流状态 + self.current_workflow_status = WorkflowStatus.IDLE + self.current_workflow_info = None + self.workflow_start_time = None + self.workflow_parameters = {} + + # 支持的工作流(静态预定义) + self.supported_workflows: Dict[str, WorkflowInfo] = {} + + # 初始化物料系统 + self._initialize_material_system() + + # 注册支持的工作流 + self._register_supported_workflows() + + logger.info(f"工作站 {device_id} 初始化完成(简化版)") + + def _initialize_material_system(self): + """初始化物料系统 - 使用 graphio 转换""" + try: + from unilabos.resources.graphio import resource_ulab_to_plr + + # 1. 合并 deck_config 和 children 创建完整的资源树 + complete_resource_config = self._create_complete_resource_config() + + # 2. 使用 graphio 转换为 PLR 资源 + self.deck = resource_ulab_to_plr(complete_resource_config, plr_model=True) + + # 3. 建立资源映射 + self._build_resource_mappings(self.deck) + + # 4. 如果有资源同步器,执行初始同步 + if self.resource_synchronizer: + # 这里可以异步执行,暂时跳过 + pass + + logger.info(f"工作站 {self.device_id} 物料系统初始化成功,创建了 {len(self.plr_resources)} 个资源") + + except Exception as e: + logger.error(f"工作站 {self.device_id} 物料系统初始化失败: {e}") + raise + + def _create_complete_resource_config(self) -> Dict[str, Any]: + """创建完整的资源配置 - 合并 deck_config 和 children""" + # 创建主 deck 配置 + deck_resource = { + "id": f"{self.device_id}_deck", + "name": f"{self.device_id}_deck", + "type": "deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "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", 100.0), + **{k: v for k, v in self.deck_config.items() if k not in ["size_x", "size_y", "size_z"]}, + }, + "data": {}, + "children": [], + "parent": None, + } + + # 添加子资源 + if self._children: + children_list = [] + for child_id, child_config in self._children.items(): + child_resource = self._normalize_child_resource(child_id, child_config, deck_resource["id"]) + children_list.append(child_resource) + deck_resource["children"] = children_list + + return deck_resource + + def _normalize_child_resource(self, resource_id: str, config: Dict[str, Any], parent_id: str) -> Dict[str, Any]: + """标准化子资源配置""" + return { + "id": resource_id, + "name": config.get("name", resource_id), + "type": config.get("type", "container"), + "position": self._normalize_position(config.get("position", {})), + "config": config.get("config", {}), + "data": config.get("data", {}), + "children": [], # 简化版本:只支持一层子资源 + "parent": parent_id, + } + + def _normalize_position(self, position: Any) -> Dict[str, float]: + """标准化位置信息""" + if isinstance(position, dict): + return { + "x": float(position.get("x", 0)), + "y": float(position.get("y", 0)), + "z": float(position.get("z", 0)), + } + elif isinstance(position, (list, tuple)) and len(position) >= 2: + return { + "x": float(position[0]), + "y": float(position[1]), + "z": float(position[2]) if len(position) > 2 else 0.0, + } + else: + return {"x": 0.0, "y": 0.0, "z": 0.0} + + def _build_resource_mappings(self, deck: Deck): + """递归构建资源映射""" + + def add_resource_recursive(resource: PLRResource): + if hasattr(resource, "name"): + self.plr_resources[resource.name] = resource + + if hasattr(resource, "children"): + for child in resource.children: + add_resource_recursive(child) + + add_resource_recursive(deck) + + # ============ 硬件接口管理 ============ + + def set_hardware_interface(self, hardware_interface: Union[Any, str]): + """设置硬件接口""" + self.hardware_interface = hardware_interface + logger.info(f"工作站 {self.device_id} 硬件接口设置: {type(hardware_interface).__name__}") + + def set_workstation_node(self, workstation_node: "ROS2WorkstationNode"): + """设置协议节点引用(用于代理模式)""" + self._ros_node = workstation_node + logger.info(f"工作站 {self.device_id} 关联协议节点") + + # ============ 设备操作接口 ============ + + def call_device_method(self, method: str, *args, **kwargs) -> Any: + """调用设备方法的统一接口""" + # 1. 代理模式:通过协议节点转发 + if isinstance(self.hardware_interface, str) and self.hardware_interface.startswith("proxy:"): + if not self._ros_node: + raise RuntimeError("代理模式需要设置workstation_node") + + device_id = self.hardware_interface[6:] # 移除 "proxy:" 前缀 + return self._ros_node.call_device_method(device_id, method, *args, **kwargs) + + # 2. 直接模式:直接调用硬件接口方法 + elif self.hardware_interface and hasattr(self.hardware_interface, method): + return getattr(self.hardware_interface, method)(*args, **kwargs) + + else: + raise AttributeError(f"硬件接口不支持方法: {method}") + + def get_device_status(self) -> Dict[str, Any]: + """获取设备状态""" + try: + return self.call_device_method("get_status") + except AttributeError: + # 如果设备不支持get_status方法,返回基础状态 + return { + "status": "unknown", + "interface_type": type(self.hardware_interface).__name__, + "timestamp": time.time(), + } + + def is_device_available(self) -> bool: + """检查设备是否可用""" + try: + self.get_device_status() + return True + except: + return False + + # ============ 物料系统接口 ============ + + def get_deck(self) -> Deck: + """获取主 Deck""" + return self.deck + + def get_all_resources(self) -> Dict[str, PLRResource]: + """获取所有 PLR 资源""" + return self.plr_resources.copy() + + def find_resource_by_name(self, name: str) -> Optional[PLRResource]: + """按名称查找资源""" + return self.plr_resources.get(name) + + def find_resources_by_type(self, resource_type: type) -> List[PLRResource]: + """按类型查找资源""" + return [res for res in self.plr_resources.values() if isinstance(res, resource_type)] + + async def sync_with_external_system(self) -> bool: + """与外部物料系统同步""" + if not self.resource_synchronizer: + logger.info(f"工作站 {self.device_id} 没有配置资源同步器") + return True + + try: + success = await self.resource_synchronizer.sync_from_external() + if success: + logger.info(f"工作站 {self.device_id} 外部同步成功") + else: + logger.warning(f"工作站 {self.device_id} 外部同步失败") + return success + except Exception as e: + logger.error(f"工作站 {self.device_id} 外部同步异常: {e}") + return False + + # ============ 简化的工作流控制 ============ + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流""" + try: + # 设置工作流状态 + self.current_workflow_status = WorkflowStatus.INITIALIZING + self.workflow_parameters = parameters + self.workflow_start_time = time.time() + + # 委托给子类实现 + success = self._execute_workflow_impl(workflow_name, parameters) + + if success: + self.current_workflow_status = WorkflowStatus.RUNNING + logger.info(f"工作站 {self.device_id} 工作流 {workflow_name} 启动成功") + else: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作站 {self.device_id} 工作流 {workflow_name} 启动失败") + + return success + + except Exception as e: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作站 {self.device_id} 执行工作流失败: {e}") + return False + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: + logger.warning(f"工作站 {self.device_id} 没有正在运行的工作流") + return True + + self.current_workflow_status = WorkflowStatus.STOPPING + + # 委托给子类实现 + success = self._stop_workflow_impl(emergency) + + if success: + self.current_workflow_status = WorkflowStatus.STOPPED + logger.info(f"工作站 {self.device_id} 工作流停止成功 (紧急: {emergency})") + else: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作站 {self.device_id} 工作流停止失败") + + return success + + except Exception as e: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作站 {self.device_id} 停止工作流失败: {e}") + return False + + # ============ 状态属性 ============ + + @property + def workflow_status(self) -> WorkflowStatus: + """获取当前工作流状态""" + return self.current_workflow_status + + @property + def is_busy(self) -> bool: + """检查工作站是否忙碌""" + return self.current_workflow_status in [ + WorkflowStatus.INITIALIZING, + WorkflowStatus.RUNNING, + WorkflowStatus.STOPPING, + ] + + @property + def workflow_runtime(self) -> float: + """获取工作流运行时间(秒)""" + if self.workflow_start_time is None: + return 0.0 + return time.time() - self.workflow_start_time + + # ============ 抽象方法 - 子类必须实现 ============ + + @abstractmethod + def _register_supported_workflows(self): + """注册支持的工作流 - 子类必须实现""" + pass + + @abstractmethod + def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流的具体实现 - 子类必须实现""" + pass + + @abstractmethod + def _stop_workflow_impl(self, emergency: bool = False) -> bool: + """停止工作流的具体实现 - 子类必须实现""" + pass + +class WorkstationExample(WorkstationBase): + """工作站示例实现""" + + def _register_supported_workflows(self): + """注册支持的工作流""" + self.supported_workflows["example_workflow"] = WorkflowInfo( + name="example_workflow", + description="这是一个示例工作流", + estimated_duration=300.0, + required_materials=["sample_plate"], + output_product="processed_plate", + parameters_schema={"param1": "string", "param2": "integer"}, + ) + + def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流的具体实现""" + if workflow_name not in self.supported_workflows: + logger.error(f"工作站 {self.device_id} 不支持工作流: {workflow_name}") + return False + + # 这里添加实际的工作流逻辑 + logger.info(f"工作站 {self.device_id} 正在执行工作流: {workflow_name} with parameters {parameters}") + return True + + def _stop_workflow_impl(self, emergency: bool = False) -> bool: + """停止工作流的具体实现""" + # 这里添加实际的停止逻辑 + logger.info(f"工作站 {self.device_id} 正在停止工作流 (紧急: {emergency})") + return True \ No newline at end of file diff --git a/unilabos/devices/workstation/workstation_http_service.py b/unilabos/devices/workstation/workstation_http_service.py new file mode 100644 index 00000000..3805d2ce --- /dev/null +++ b/unilabos/devices/workstation/workstation_http_service.py @@ -0,0 +1,605 @@ +""" +工作站HTTP服务模块 +Workstation HTTP Service Module + +统一的工作站报送接收服务,基于LIMS协议规范: +1. 步骤完成报送 - POST /report/step_finish +2. 通量完成报送 - POST /report/sample_finish +3. 任务完成报送 - POST /report/order_finish +4. 批量更新报送 - POST /report/batch_update +5. 物料变更报送 - POST /report/material_change +6. 错误处理报送 - POST /report/error_handling +7. 健康检查和状态查询 + +统一使用LIMS协议字段规范,简化接口避免功能重复 +""" +import json +import threading +import time +import traceback +from typing import Dict, Any, Optional, List +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse +from dataclasses import dataclass, asdict +from datetime import datetime + +from unilabos.utils.log import logger + + +@dataclass +class WorkstationReportRequest: + """统一工作站报送请求(基于LIMS协议规范)""" + token: str # 授权令牌 + request_time: str # 请求时间,格式:2024-12-12 12:12:12.xxx + data: Dict[str, Any] # 报送数据 + + +@dataclass +class MaterialUsage: + """物料使用记录""" + materialId: str # 物料Id(GUID) + locationId: str # 库位Id(GUID) + typeMode: str # 物料类型(样品1、试剂2、耗材0) + usedQuantity: float # 使用的数量(数字) + + +@dataclass +class HttpResponse: + """HTTP响应""" + success: bool + message: str + data: Optional[Dict[str, Any]] = None + acknowledgment_id: Optional[str] = None + + +class WorkstationHTTPHandler(BaseHTTPRequestHandler): + """工作站HTTP请求处理器""" + + def __init__(self, workstation_instance, *args, **kwargs): + self.workstation = workstation_instance + super().__init__(*args, **kwargs) + + def do_POST(self): + """处理POST请求 - 统一的工作站报送接口""" + try: + # 解析请求路径 + parsed_path = urlparse(self.path) + endpoint = parsed_path.path + + # 读取请求体 + content_length = int(self.headers.get('Content-Length', 0)) + if content_length > 0: + post_data = self.rfile.read(content_length) + request_data = json.loads(post_data.decode('utf-8')) + else: + request_data = {} + + logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}") + + # 统一的报送端点路由(基于LIMS协议规范) + if endpoint == '/report/step_finish': + response = self._handle_step_finish_report(request_data) + elif endpoint == '/report/sample_finish': + response = self._handle_sample_finish_report(request_data) + elif endpoint == '/report/order_finish': + response = self._handle_order_finish_report(request_data) + elif endpoint == '/report/batch_update': + response = self._handle_batch_update_report(request_data) + # 扩展报送端点 + elif endpoint == '/report/material_change': + response = self._handle_material_change_report(request_data) + elif endpoint == '/report/error_handling': + response = self._handle_error_handling_report(request_data) + # 保留LIMS协议端点以兼容现有系统 + elif endpoint == '/LIMS/step_finish': + response = self._handle_step_finish_report(request_data) + elif endpoint == '/LIMS/preintake_finish': + response = self._handle_sample_finish_report(request_data) + elif endpoint == '/LIMS/order_finish': + response = self._handle_order_finish_report(request_data) + else: + response = HttpResponse( + success=False, + message=f"不支持的报送端点: {endpoint}", + data={"supported_endpoints": [ + "/report/step_finish", + "/report/sample_finish", + "/report/order_finish", + "/report/batch_update", + "/report/material_change", + "/report/error_handling" + ]} + ) + + # 发送响应 + self._send_response(response) + + except Exception as e: + logger.error(f"处理工作站报送失败: {e}\\n{traceback.format_exc()}") + error_response = HttpResponse( + success=False, + message=f"请求处理失败: {str(e)}" + ) + self._send_response(error_response) + + def do_GET(self): + """处理GET请求 - 健康检查和状态查询""" + try: + parsed_path = urlparse(self.path) + endpoint = parsed_path.path + + if endpoint == '/status': + response = self._handle_status_check() + elif endpoint == '/health': + response = HttpResponse(success=True, message="服务健康") + else: + response = HttpResponse( + success=False, + message=f"不支持的查询端点: {endpoint}", + data={"supported_endpoints": ["/status", "/health"]} + ) + + self._send_response(response) + + except Exception as e: + logger.error(f"GET请求处理失败: {e}") + error_response = HttpResponse( + success=False, + message=f"GET请求处理失败: {str(e)}" + ) + self._send_response(error_response) + + def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理步骤完成报送(统一LIMS协议规范)""" + try: + # 验证基本字段 + required_fields = ['token', 'request_time', 'data'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 验证data字段内容 + data = request_data['data'] + data_required_fields = ['orderCode', 'orderName', 'stepName', 'stepId', 'sampleId', 'startTime', 'endTime'] + if data_missing_fields := [field for field in data_required_fields if field not in data]: + return HttpResponse( + success=False, + message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" + ) + + # 创建统一请求对象 + report_request = WorkstationReportRequest( + token=request_data['token'], + request_time=request_data['request_time'], + data=data + ) + + # 调用工作站处理方法 + result = self.workstation.process_step_finish_report(report_request) + + return HttpResponse( + success=True, + message=f"步骤完成报送已处理: {data['stepName']} ({data['orderCode']})", + acknowledgment_id=f"STEP_{int(time.time() * 1000)}_{data['stepId']}", + data=result + ) + + except Exception as e: + logger.error(f"处理步骤完成报送失败: {e}") + return HttpResponse( + success=False, + message=f"步骤完成报送处理失败: {str(e)}" + ) + + def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理通量完成报送(统一LIMS协议规范)""" + try: + # 验证基本字段 + required_fields = ['token', 'request_time', 'data'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 验证data字段内容 + data = request_data['data'] + data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'Status'] + if data_missing_fields := [field for field in data_required_fields if field not in data]: + return HttpResponse( + success=False, + message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" + ) + + # 创建统一请求对象 + report_request = WorkstationReportRequest( + token=request_data['token'], + request_time=request_data['request_time'], + data=data + ) + + # 调用工作站处理方法 + result = self.workstation.process_sample_finish_report(report_request) + + status_names = { + "0": "待生产", "2": "进样", "10": "开始", + "20": "完成", "-2": "异常停止", "-3": "人工停止" + } + status_desc = status_names.get(str(data['Status']), f"状态{data['Status']}") + + return HttpResponse( + success=True, + message=f"通量完成报送已处理: {data['sampleId']} ({data['orderCode']}) - {status_desc}", + acknowledgment_id=f"SAMPLE_{int(time.time() * 1000)}_{data['sampleId']}", + data=result + ) + + except Exception as e: + logger.error(f"处理通量完成报送失败: {e}") + return HttpResponse( + success=False, + message=f"通量完成报送处理失败: {str(e)}" + ) + + def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理任务完成报送(统一LIMS协议规范)""" + try: + # 验证基本字段 + required_fields = ['token', 'request_time', 'data'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 验证data字段内容 + data = request_data['data'] + data_required_fields = ['orderCode', 'orderName', 'startTime', 'endTime', 'status'] + if data_missing_fields := [field for field in data_required_fields if field not in data]: + return HttpResponse( + success=False, + message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" + ) + + # 处理物料使用记录 + used_materials = [] + if 'usedMaterials' in data: + for material_data in data['usedMaterials']: + material = MaterialUsage( + materialId=material_data.get('materialId', ''), + locationId=material_data.get('locationId', ''), + typeMode=material_data.get('typeMode', ''), + usedQuantity=material_data.get('usedQuantity', 0.0) + ) + used_materials.append(material) + + # 创建统一请求对象 + report_request = WorkstationReportRequest( + token=request_data['token'], + request_time=request_data['request_time'], + data=data + ) + + # 调用工作站处理方法 + result = self.workstation.process_order_finish_report(report_request, used_materials) + + status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"} + status_desc = status_names.get(str(data['status']), f"状态{data['status']}") + + return HttpResponse( + success=True, + message=f"任务完成报送已处理: {data['orderName']} ({data['orderCode']}) - {status_desc}", + acknowledgment_id=f"ORDER_{int(time.time() * 1000)}_{data['orderCode']}", + data=result + ) + + except Exception as e: + logger.error(f"处理任务完成报送失败: {e}") + return HttpResponse( + success=False, + message=f"任务完成报送处理失败: {str(e)}" + ) + + def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理批量报送""" + try: + step_updates = request_data.get('step_updates', []) + sample_updates = request_data.get('sample_updates', []) + order_updates = request_data.get('order_updates', []) + + results = { + 'step_results': [], + 'sample_results': [], + 'order_results': [], + 'total_processed': 0, + 'total_failed': 0 + } + + # 处理批量步骤更新 + for step_data in step_updates: + try: + step_data['token'] = request_data.get('token', step_data.get('token')) + step_data['request_time'] = request_data.get('request_time', step_data.get('request_time')) + result = self._handle_step_finish_report(step_data) + results['step_results'].append(result) + if result.success: + results['total_processed'] += 1 + else: + results['total_failed'] += 1 + except Exception as e: + results['step_results'].append(HttpResponse(success=False, message=str(e))) + results['total_failed'] += 1 + + # 处理批量通量更新 + for sample_data in sample_updates: + try: + sample_data['token'] = request_data.get('token', sample_data.get('token')) + sample_data['request_time'] = request_data.get('request_time', sample_data.get('request_time')) + result = self._handle_sample_finish_report(sample_data) + results['sample_results'].append(result) + if result.success: + results['total_processed'] += 1 + else: + results['total_failed'] += 1 + except Exception as e: + results['sample_results'].append(HttpResponse(success=False, message=str(e))) + results['total_failed'] += 1 + + # 处理批量任务更新 + for order_data in order_updates: + try: + order_data['token'] = request_data.get('token', order_data.get('token')) + order_data['request_time'] = request_data.get('request_time', order_data.get('request_time')) + result = self._handle_order_finish_report(order_data) + results['order_results'].append(result) + if result.success: + results['total_processed'] += 1 + else: + results['total_failed'] += 1 + except Exception as e: + results['order_results'].append(HttpResponse(success=False, message=str(e))) + results['total_failed'] += 1 + + return HttpResponse( + success=results['total_failed'] == 0, + message=f"批量报送处理完成: {results['total_processed']} 成功, {results['total_failed']} 失败", + acknowledgment_id=f"BATCH_{int(time.time() * 1000)}", + data=results + ) + + except Exception as e: + logger.error(f"处理批量报送失败: {e}") + return HttpResponse( + success=False, + message=f"批量报送处理失败: {str(e)}" + ) + + def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理物料变更报送""" + try: + # 验证必需字段 + required_fields = ['workstation_id', 'timestamp', 'resource_id', 'change_type'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 调用工作站的处理方法 + result = self.workstation.process_material_change_report(request_data) + + return HttpResponse( + success=True, + message=f"物料变更报送已处理: {request_data['resource_id']} ({request_data['change_type']})", + acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{request_data['resource_id']}", + data=result + ) + + except Exception as e: + logger.error(f"处理物料变更报送失败: {e}") + return HttpResponse( + success=False, + message=f"物料变更报送处理失败: {str(e)}" + ) + + def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理错误处理报送""" + try: + # 验证必需字段 + required_fields = ['workstation_id', 'timestamp', 'error_type', 'error_message'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 调用工作站的处理方法 + result = self.workstation.handle_external_error(request_data) + + return HttpResponse( + success=True, + message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}", + acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}", + data=result + ) + + except Exception as e: + logger.error(f"处理错误处理报送失败: {e}") + return HttpResponse( + success=False, + message=f"错误处理报送处理失败: {str(e)}" + ) + + def _handle_status_check(self) -> HttpResponse: + """处理状态查询""" + try: + return HttpResponse( + success=True, + message="工作站报送服务正常运行", + data={ + "workstation_id": self.workstation.device_id, + "service_type": "unified_reporting_service", + "uptime": time.time() - getattr(self.workstation, '_start_time', time.time()), + "reports_received": getattr(self.workstation, '_reports_received_count', 0), + "supported_endpoints": [ + "POST /report/step_finish", + "POST /report/sample_finish", + "POST /report/order_finish", + "POST /report/batch_update", + "POST /report/material_change", + "POST /report/error_handling", + "GET /status", + "GET /health" + ] + } + ) + except Exception as e: + logger.error(f"处理状态查询失败: {e}") + return HttpResponse( + success=False, + message=f"状态查询失败: {str(e)}" + ) + + def _send_response(self, response: HttpResponse): + """发送响应""" + try: + # 设置响应状态码 + status_code = 200 if response.success else 400 + self.send_response(status_code) + + # 设置响应头 + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type') + self.end_headers() + + # 发送响应体 + response_json = json.dumps(asdict(response), ensure_ascii=False, indent=2) + self.wfile.write(response_json.encode('utf-8')) + + except Exception as e: + logger.error(f"发送响应失败: {e}") + + def log_message(self, format, *args): + """重写日志方法""" + logger.debug(f"HTTP请求: {format % args}") + + +class WorkstationHTTPService: + """工作站HTTP服务""" + + def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 8080): + self.workstation = workstation_instance + self.host = host + self.port = port + self.server = None + self.server_thread = None + self.running = False + + # 初始化统计信息 + self.workstation._start_time = time.time() + self.workstation._reports_received_count = 0 + + def start(self): + """启动HTTP服务""" + try: + # 创建处理器工厂函数 + def handler_factory(*args, **kwargs): + return WorkstationHTTPHandler(self.workstation, *args, **kwargs) + + # 创建HTTP服务器 + self.server = HTTPServer((self.host, self.port), handler_factory) + + # 在单独线程中运行服务器 + self.server_thread = threading.Thread( + target=self._run_server, + daemon=True, + name=f"WorkstationHTTP-{self.workstation.device_id}" + ) + + self.running = True + self.server_thread.start() + + logger.info(f"工作站HTTP报送服务已启动: http://{self.host}:{self.port}") + logger.info("统一的报送端点 (基于LIMS协议规范):") + logger.info(" - POST /report/step_finish # 步骤完成报送") + logger.info(" - POST /report/sample_finish # 通量完成报送") + logger.info(" - POST /report/order_finish # 任务完成报送") + logger.info(" - POST /report/batch_update # 批量更新报送") + logger.info("扩展报送端点:") + logger.info(" - POST /report/material_change # 物料变更报送") + logger.info(" - POST /report/error_handling # 错误处理报送") + logger.info("兼容端点:") + logger.info(" - POST /LIMS/step_finish # 兼容LIMS步骤完成") + logger.info(" - POST /LIMS/preintake_finish # 兼容LIMS通量完成") + logger.info(" - POST /LIMS/order_finish # 兼容LIMS任务完成") + logger.info("服务端点:") + logger.info(" - GET /status # 服务状态查询") + logger.info(" - GET /health # 健康检查") + + except Exception as e: + logger.error(f"启动HTTP服务失败: {e}") + raise + + def stop(self): + """停止HTTP服务""" + try: + if self.running and self.server: + self.running = False + self.server.shutdown() + self.server.server_close() + + if self.server_thread and self.server_thread.is_alive(): + self.server_thread.join(timeout=5.0) + + logger.info("工作站HTTP报送服务已停止") + + except Exception as e: + logger.error(f"停止HTTP服务失败: {e}") + + def _run_server(self): + """运行HTTP服务器""" + try: + while self.running: + self.server.handle_request() + except Exception as e: + if self.running: # 只在非正常停止时记录错误 + logger.error(f"HTTP服务运行错误: {e}") + + @property + def is_running(self) -> bool: + """检查服务是否正在运行""" + return self.running and self.server_thread and self.server_thread.is_alive() + + @property + def service_url(self) -> str: + """获取服务URL""" + return f"http://{self.host}:{self.port}" + + +# 导出主要类 - 保持向后兼容 +@dataclass +class MaterialChangeReport: + """已废弃:物料变更报送,请使用统一的WorkstationReportRequest""" + pass + + +@dataclass +class TaskExecutionReport: + """已废弃:任务执行报送,请使用统一的WorkstationReportRequest""" + pass + + +# 导出列表 +__all__ = [ + 'WorkstationReportRequest', + 'MaterialUsage', + 'HttpResponse', + 'WorkstationHTTPService', + # 向后兼容 + 'MaterialChangeReport', + 'TaskExecutionReport' +] diff --git a/unilabos/devices/workstation/workstation_material_management.py b/unilabos/devices/workstation/workstation_material_management.py new file mode 100644 index 00000000..a9229130 --- /dev/null +++ b/unilabos/devices/workstation/workstation_material_management.py @@ -0,0 +1,583 @@ +""" +工作站物料管理基类 +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/resources/graphio.py b/unilabos/resources/graphio.py index 697c08ab..6afee153 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -7,13 +7,13 @@ import networkx as nx from unilabos_msgs.msg import Resource from unilabos.resources.container import RegularContainer -from unilabos.ros.msgs.message_converter import convert_from_ros_msg_with_mapping, convert_to_ros_msg +from unilabos.ros.msgs.message_converter import convert_to_ros_msg try: from pylabrobot.resources.resource import Resource as ResourcePLR except ImportError: pass -from typing import Union, get_origin, get_args +from typing import Union, get_origin physical_setup_graph: nx.Graph = None @@ -327,7 +327,7 @@ def nested_dict_to_list(nested_dict: dict) -> list[dict]: # FIXME 是tree? return result def convert_resources_to_type( - resources_list: list[dict], resource_type: type, *, plr_model: bool = False + resources_list: list[dict], resource_type: Union[type, list[type]], *, plr_model: bool = False ) -> Union[list[dict], dict, None, "ResourcePLR"]: """ Convert resources to a given type (PyLabRobot or NestedDict) from flattened list of dictionaries. @@ -358,7 +358,7 @@ def convert_resources_to_type( return None -def convert_resources_from_type(resources_list, resource_type: type) -> Union[list[dict], dict, None, "ResourcePLR"]: +def convert_resources_from_type(resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False) -> Union[list[dict], dict, None, "ResourcePLR"]: """ Convert resources from a given type (PyLabRobot or NestedDict) to flattened list of dictionaries. @@ -374,11 +374,11 @@ def convert_resources_from_type(resources_list, resource_type: type) -> Union[li elif isinstance(resource_type, type) and issubclass(resource_type, ResourcePLR): resources_tree = [resource_plr_to_ulab(resources_list)] return tree_to_list(resources_tree) - elif isinstance(resource_type, list) : + elif isinstance(resource_type, list): if all((get_origin(t) is Union) for t in resource_type): resources_tree = [resource_plr_to_ulab(r) for r in resources_list] return tree_to_list(resources_tree) - elif all(issubclass(t, ResourcePLR) for t in resource_type): + elif is_plr or all(issubclass(t, ResourcePLR) for t in resource_type): resources_tree = [resource_plr_to_ulab(r) for r in resources_list] return tree_to_list(resources_tree) else: diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index 84e8529e..e8570d31 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -51,6 +51,8 @@ SendCmd = msg_converter_manager.get_class("unilabos_msgs.action:SendCmd") imsg = msg_converter_manager.get_module("unilabos.messages") Point3D = msg_converter_manager.get_class("unilabos.messages:Point3D") +from control_msgs.action import * + # 基本消息类型映射 _msg_mapping: Dict[Type, Type] = { float: Float64, diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index a85747f4..e8b1717b 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -518,6 +518,17 @@ class BaseROS2DeviceNode(Node, Generic[T]): rclpy.get_global_executor().add_node(self) self.lab_logger().debug(f"ROS节点初始化完成") + async def update_resource(self, resources: List[Any]): + r = ResourceUpdate.Request() + unique_resources = [] + for resource in resources: # resource是list[ResourcePLR] + # 目前更新资源只支持传入plr的对象,后面要更新convert_resources_from_type函数 + converted_list = convert_resources_from_type([resource], resource_type=[object], is_plr=True) + unique_resources.extend([convert_to_ros_msg(Resource, converted) for converted in converted_list]) + r.resources = unique_resources + response = await self._resource_clients["resource_update"].call_async(r) + self.lab_logger().debug(f"资源更新结果: {response}") + def register_device(self): """向注册表中注册设备信息""" topics_info = self._property_publishers.copy() @@ -947,6 +958,7 @@ class ROS2DeviceNode: self._driver_class = driver_class self.device_config = device_config self.driver_is_ros = driver_is_ros + self.driver_is_workstation = False self.resource_tracker = DeviceNodeResourceTracker() # use_pylabrobot_creator 使用 cls的包路径检测 @@ -967,10 +979,11 @@ class ROS2DeviceNode: driver_class, children=children, resource_tracker=self.resource_tracker ) else: - from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode + from unilabos.devices.workstation.workstation_base import WorkstationBase - if issubclass(self._driver_class, ROS2ProtocolNode): # 是ProtocolNode的子节点,就要调用ProtocolNodeCreator - self._driver_creator = ProtocolNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) + if issubclass(self._driver_class, WorkstationBase): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator + self.driver_is_workstation = True + self._driver_creator = WorkstationNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) else: self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) @@ -985,6 +998,19 @@ class ROS2DeviceNode: # 创建ROS2节点 if driver_is_ros: self._ros_node = self._driver_instance # type: ignore + elif self.driver_is_workstation: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode + self._ros_node = ROS2WorkstationNode( + protocol_type=driver_params["protocol_type"], + children=children, + driver_instance=self._driver_instance, # type: ignore + device_id=device_id, + status_types=status_types, + action_value_mappings=action_value_mappings, + hardware_interface=hardware_interface, + print_publish=print_publish, + resource_tracker=self.resource_tracker, + ) else: self._ros_node = BaseROS2DeviceNode( driver_instance=self._driver_instance, diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index eb36f0a8..27f53cba 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -553,7 +553,7 @@ class HostNode(BaseROS2DeviceNode): # 解析设备名和属性名 parts = topic.split("/") - if len(parts) >= 4: # 可能有ProtocolNode,创建更长的设备 + if len(parts) >= 4: # 可能有WorkstationNode,创建更长的设备 device_id = "/".join(parts[2:-1]) property_name = parts[-1] diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index 0e84683e..4e2dbef8 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -1,86 +1,413 @@ -import collections -from typing import Union, Dict, Any, Optional +import json +import time +import traceback +from pprint import pformat +from typing import List, Dict, Any, Optional, TYPE_CHECKING -from unilabos_msgs.msg import Resource -from pylabrobot.resources import Resource as PLRResource, Plate, TipRack, Coordinate -from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +import rclpy +from rosidl_runtime_py import message_to_ordereddict + +from unilabos.messages import * # type: ignore # protocol names +from rclpy.action import ActionServer, ActionClient +from rclpy.action.server import ServerGoalHandle +from rclpy.callback_groups import ReentrantCallbackGroup +from unilabos_msgs.msg import Resource # type: ignore +from unilabos_msgs.srv import ResourceGet, ResourceUpdate # type: ignore + +from unilabos.compile import action_protocol_generators +from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list +from unilabos.ros.initialize_device import initialize_device_from_dict +from unilabos.ros.msgs.message_converter import ( + get_action_type, + convert_to_ros_msg, + convert_from_ros_msg, + convert_from_ros_msg_with_mapping, +) +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode +from unilabos.utils.type_check import serialize_result_info + +if TYPE_CHECKING: + from unilabos.devices.workstation.workstation_base import WorkstationBase -class WorkStationContainer(Plate, TipRack): +class ROS2WorkstationNode(BaseROS2DeviceNode): """ - WorkStation 专用 Container 类,继承自 Plate和TipRack - 注意这个物料必须通过plr_additional_res_reg.py注册到edge,才能正常序列化 + ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。 + 它初始化设备节点,处理动作客户端,并基于指定的协议执行工作流。 + 它还物理上代表一组协同工作的设备,如带夹持器的机械臂,带传送带的CNC机器等。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, category: str, ordering: collections.OrderedDict, model: Optional[str] = None,): - """ - 这里的初始化入参要和plr的保持一致 - """ - super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) - self._unilabos_state = {} # 必须有此行,自己的类描述的是物料的 + driver_instance: "WorkstationBase" - def load_state(self, state: Dict[str, Any]) -> None: - """从给定的状态加载工作台信息。""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - - -def get_workstation_plate_resource(name: str) -> PLRResource: # 要给定一个返回plr的方法 - """ - 用于获取一些模板,例如返回一个带有特定信息/子物料的 Plate,这里需要到注册表注册,例如unilabos/registry/resources/organic/workstation.yaml - 可以直接运行该函数或者利用注册表补全机制,来检查是否资源出错 - :param name: 资源名称 - :return: Resource对象 - """ - plate = WorkStationContainer(name, size_x=50, size_y=50, size_z=10, category="plate", ordering=collections.OrderedDict()) - tip_rack = WorkStationContainer("tip_rack_inside_plate", size_x=50, size_y=50, size_z=10, category="tip_rack", ordering=collections.OrderedDict()) - plate.assign_child_resource(tip_rack, Coordinate.zero()) - return plate - - -class WorkStationExample(ROS2ProtocolNode): - def __init__(self, - # 你可以在这里增加任意的参数,对应启动json填写相应的参数内容 - device_id: str, - children: dict, - protocol_type: Union[str, list[str]], - resource_tracker: DeviceNodeResourceTracker - ): - super().__init__(device_id, children, protocol_type, resource_tracker) - - def create_resource( + def __init__( self, - resource_tracker: DeviceNodeResourceTracker, - resources: list[Resource], - bind_parent_id: str, - bind_location: dict[str, float], - liquid_input_slot: list[int], - liquid_type: list[str], - liquid_volume: list[int], - slot_on_deck: int, - ) -> Dict[str, Any]: - return { # edge侧返回给前端的创建物料的结果。云端调用初始化瓶子等。执行该函数时,物料已经上报给云端,一般不需要继承使用 + protocol_type: List[str], + children: Dict[str, Any], + *, + driver_instance: "WorkstationBase", + device_id: str, + status_types: Dict[str, Any], + action_value_mappings: Dict[str, Any], + hardware_interface: Dict[str, Any], + print_publish=True, + resource_tracker: Optional["DeviceNodeResourceTracker"] = None, + ): + self._setup_protocol_names(protocol_type) - } + # 初始化非BaseROSNode的属性 + self.children = children + # 初始化基类,让基类处理常规动作 + super().__init__( + driver_instance=driver_instance, + device_id=device_id, + status_types=status_types, + action_value_mappings={ + **action_value_mappings, + **self.protocol_action_mappings + }, + hardware_interface=hardware_interface, + print_publish=print_publish, + resource_tracker=resource_tracker, + ) - def transfer_bottle(self, tip_rack: PLRResource, base_plate: PLRResource): # 使用自定义物料的举例 - """ - 将tip_rack assign给base_plate,两个入参都得是PLRResource,unilabos会代替当前物料操作,自动刷新他们的父子关系等状态 - """ - pass + self._busy = False + self.sub_devices = {} + self._action_clients = {} - def trigger_resource_update(self, from_plate: PLRResource, to_base_plate: PLRResource): - """ - 有些时候物料发生了子设备的迁移,一般对该设备的最大一级的物料进行操作,例如要将A物料搬移到B物料上,他们不共同拥有一个物料 - 该步骤操作结束后,会主动刷新from_plate的父物料,与to_base_plate的父物料(如没有则刷新自身) + # 初始化子设备 + self.communication_node_id_to_instance = {} - """ - to_base_plate.assign_child_resource(from_plate, Coordinate.zero()) - pass + for device_id, device_config in self.children.items(): + if device_config.get("type", "device") != "device": + self.lab_logger().debug( + f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." + ) + continue + try: + d = self.initialize_device(device_id, device_config) + except Exception as ex: + self.lab_logger().error( + f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}") + d = None + if d is None: + continue + if "serial_" in device_id or "io_" in device_id: + self.communication_node_id_to_instance[device_id] = d + continue + + for device_id, device_config in self.children.items(): + if device_config.get("type", "device") != "device": + continue + # 设置硬件接口代理 + if device_id not in self.sub_devices: + self.lab_logger().error(f"[Protocol Node] {device_id} 还没有正确初始化,跳过...") + continue + d = self.sub_devices[device_id] + if d: + hardware_interface = d.ros_node_instance._hardware_interface + if ( + hasattr(d.driver_instance, hardware_interface["name"]) + and hasattr(d.driver_instance, hardware_interface["write"]) + and ( + hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"])) + ): + + name = getattr(d.driver_instance, hardware_interface["name"]) + read = hardware_interface.get("read", None) + write = hardware_interface.get("write", None) + + # 如果硬件接口是字符串,通过通信设备提供 + if isinstance(name, str) and name in self.sub_devices: + communicate_device = self.sub_devices[name] + communicate_hardware_info = communicate_device.ros_node_instance._hardware_interface + self._setup_hardware_proxy(d, self.sub_devices[name], read, write) + self.lab_logger().info( + f"\n通信代理:为子设备{device_id}\n " + f"添加了{read}方法(来源:{name} {communicate_hardware_info['write']}) \n " + f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})" + ) + + self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}") + + def _setup_protocol_names(self, protocol_type): + # 处理协议类型 + if isinstance(protocol_type, str): + if "," not in protocol_type: + self.protocol_names = [protocol_type] + else: + self.protocol_names = [protocol.strip() for protocol in protocol_type.split(",")] + else: + self.protocol_names = protocol_type + # 准备协议相关的动作值映射 + self.protocol_action_mappings = {} + for protocol_name in self.protocol_names: + protocol_type = globals()[protocol_name] + self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) + + def initialize_device(self, device_id, device_config): + """初始化设备并创建相应的动作客户端""" + # device_id_abs = f"{self.device_id}/{device_id}" + device_id_abs = f"{device_id}" + self.lab_logger().info(f"初始化子设备: {device_id_abs}") + d = self.sub_devices[device_id] = initialize_device_from_dict(device_id_abs, device_config) + + # 为子设备的每个动作创建动作客户端 + if d is not None and hasattr(d, "ros_node_instance"): + node = d.ros_node_instance + node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器 + for action_name, action_mapping in node._action_value_mappings.items(): + if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith( + "UniLabJsonCommand"): + continue + action_id = f"/devices/{device_id_abs}/{action_name}" + if action_id not in self._action_clients: + try: + self._action_clients[action_id] = ActionClient( + self, action_mapping["type"], action_id, callback_group=self.callback_group + ) + except Exception as ex: + self.lab_logger().error(f"创建动作客户端失败: {action_id}, 错误: {ex}") + continue + self.lab_logger().trace(f"为子设备 {device_id} 创建动作客户端: {action_name}") + return d + + def create_ros_action_server(self, action_name, action_value_mapping): + """创建ROS动作服务器""" + if action_name not in self.protocol_names: + # 非protocol方法调用父类注册 + return super().create_ros_action_server(action_name, action_value_mapping) + # 和Base创建的路径是一致的 + protocol_name = action_name + action_type = action_value_mapping["type"] + str_action_type = str(action_type)[8:-2] + protocol_type = globals()[protocol_name] + protocol_steps_generator = action_protocol_generators[protocol_type] + + self._action_servers[action_name] = ActionServer( + self, + action_type, + action_name, + execute_callback=self._create_protocol_execute_callback(action_name, protocol_steps_generator), + callback_group=ReentrantCallbackGroup(), + ) + self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}") + return + + def _create_protocol_execute_callback(self, protocol_name, protocol_steps_generator): + async def execute_protocol(goal_handle: ServerGoalHandle): + """执行完整的工作流""" + # 初始化结果信息变量 + execution_error = "" + execution_success = False + protocol_return_value = None + self.get_logger().info(f"Executing {protocol_name} action...") + action_value_mapping = self._action_value_mappings[protocol_name] + step_results = [] + try: + print("+" * 30) + print(protocol_steps_generator) + # 从目标消息中提取参数, 并调用Protocol生成器(根据设备连接图)生成action步骤 + goal = goal_handle.request + protocol_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) + + # # 🔧 添加调试信息 + # print(f"🔍 转换后的 protocol_kwargs: {protocol_kwargs}") + # print(f"🔍 vessel 在转换后: {protocol_kwargs.get('vessel', 'NOT_FOUND')}") + + # # 🔧 完全禁用Host查询,直接使用转换后的数据 + # print(f"🔧 跳过Host查询,直接使用转换后的数据") + # 向Host查询物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + r = ResourceGet.Request() + resource_id = ( + protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"] + ) + r.id = resource_id + r.with_children = True + response = await self._resource_clients["resource_get"].call_async(r) + protocol_kwargs[k] = list_to_nested_dict( + [convert_from_ros_msg(rs) for rs in response.resources] + ) + + self.lab_logger().info(f"🔍 最终的 vessel: {protocol_kwargs.get('vessel', 'NOT_FOUND')}") + + from unilabos.resources.graphio import physical_setup_graph + + self.lab_logger().info(f"Working on physical setup: {physical_setup_graph}") + protocol_steps = protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) + logs = [] + for step in protocol_steps: + if isinstance(step, dict) and "log_message" in step.get("action_kwargs", {}): + logs.append(step) + elif isinstance(step, list): + logs.append(step) + self.lab_logger().info(f"Goal received: {protocol_kwargs}, running steps: " + f"{json.dumps(logs, indent=4, ensure_ascii=False)}") + + time_start = time.time() + time_overall = 100 + self._busy = True + + # 逐步执行工作流 + for i, action in enumerate(protocol_steps): + # self.get_logger().info(f"Running step {i + 1}: {action}") + if isinstance(action, dict): + # 如果是单个动作,直接执行 + if action["action_name"] == "wait": + time.sleep(action["action_kwargs"]["time"]) + step_results.append({"step": i + 1, "action": "wait", "result": "completed"}) + else: + result = await self.execute_single_action(**action) + step_results.append({"step": i + 1, "action": action["action_name"], "result": result}) + ret_info = json.loads(getattr(result, "return_info", "{}")) + if not ret_info.get("suc", False): + raise RuntimeError(f"Step {i + 1} failed.") + elif isinstance(action, list): + # 如果是并行动作,同时执行 + actions = action + futures = [ + rclpy.get_global_executor().create_task(self.execute_single_action(**a)) for a in actions + ] + results = [await f for f in futures] + step_results.append( + { + "step": i + 1, + "parallel_actions": [a["action_name"] for a in actions], + "results": results, + } + ) + + # 向Host更新物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + r = ResourceUpdate.Request() + r.resources = [ + convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) + ] + response = await self._resource_clients["resource_update"].call_async(r) + + # 设置成功状态和返回值 + execution_success = True + protocol_return_value = { + "protocol_name": protocol_name, + "steps_executed": len(protocol_steps), + "step_results": step_results, + "total_time": time.time() - time_start, + } + + goal_handle.succeed() + + except Exception as e: + # 捕获并记录错误信息 + str_step_results = [ + {k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v for k, v in + i.items()} for i in step_results] + execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}" + execution_success = False + self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}") + + # 设置动作失败 + goal_handle.abort() + + finally: + self._busy = False + + # 创建结果消息 + result = action_value_mapping["type"].Result() + result.success = execution_success + + # 获取结果消息类型信息,检查是否有return_info字段 + result_msg_types = action_value_mapping["type"].Result.get_fields_and_field_types() + + # 设置return_info字段(如果存在) + for attr_name in result_msg_types.keys(): + if attr_name in ["success", "reached_goal"]: + setattr(result, attr_name, execution_success) + elif attr_name == "return_info": + setattr( + result, + attr_name, + serialize_result_info(execution_error, execution_success, protocol_return_value), + ) + + self.lab_logger().info(f"协议 {protocol_name} 完成并返回结果") + return result + + return execute_protocol + + async def execute_single_action(self, device_id, action_name, action_kwargs): + """执行单个动作""" + # 构建动作ID + if device_id in ["", None, "self"]: + action_id = f"/devices/{self.device_id}/{action_name}" + else: + action_id = f"/devices/{device_id}/{action_name}" # 执行时取消了主节点信息 /{self.device_id} + + # 检查动作客户端是否存在 + if action_id not in self._action_clients: + self.lab_logger().error(f"找不到动作客户端: {action_id}") + return None + + # 发送动作请求 + action_client = self._action_clients[action_id] + goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) + + ##### self.lab_logger().info(f"发送动作请求到: {action_id}") + action_client.wait_for_server() + + # 等待动作完成 + request_future = action_client.send_goal_async(goal_msg) + handle = await request_future + + if not handle.accepted: + self.lab_logger().error(f"动作请求被拒绝: {action_name}") + return None + + result_future = await handle.get_result_async() + ##### self.lab_logger().info(f"动作完成: {action_name}") + + return result_future.result + + """还没有改过的部分""" + + def _setup_hardware_proxy( + self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method + ): + """为设备设置硬件接口代理""" + # extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])] + write_func = getattr( + communication_device.driver_instance, communication_device.ros_node_instance._hardware_interface["write"] + ) + read_func = getattr( + communication_device.driver_instance, communication_device.ros_node_instance._hardware_interface["read"] + ) + + def _read(*args, **kwargs): + return read_func(*args, **kwargs) + + def _write(*args, **kwargs): + return write_func(*args, **kwargs) + + if read_method: + # bound_read = MethodType(_read, device.driver_instance) + setattr(device.driver_instance, read_method, _read) + + if write_method: + # bound_write = MethodType(_write, device.driver_instance) + setattr(device.driver_instance, write_method, _write) + + async def _update_resources(self, goal, protocol_kwargs): + """更新资源状态""" + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + if protocol_kwargs[k] is not None: + try: + r = ResourceUpdate.Request() + r.resources = [ + convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) + ] + await self._resource_clients["resource_update"].call_async(r) + except Exception as e: + self.lab_logger().error(f"更新资源失败: {e}") \ No newline at end of file diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index a96c4459..06fc1c27 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1,7 +1,12 @@ -from typing import List, Tuple, Any +from typing import List, Tuple, Any, Dict, TYPE_CHECKING +from abc import ABC, abstractmethod from unilabos.utils.log import logger +if TYPE_CHECKING: + from unilabos.devices.workstation.workstation_base import WorkstationBase + from pylabrobot.resources import Resource as PLRResource + class DeviceNodeResourceTracker(object): @@ -37,10 +42,20 @@ class DeviceNodeResourceTracker(object): def figure_resource(self, query_resource, try_mode=False): if isinstance(query_resource, list): return [self.figure_resource(r, try_mode) for r in query_resource] - elif isinstance(query_resource, dict) and "id" not in query_resource and "name" not in query_resource: # 临时处理,要删除的,driver有太多类型错误标注 + elif ( + isinstance(query_resource, dict) and "id" not in query_resource and "name" not in query_resource + ): # 临时处理,要删除的,driver有太多类型错误标注 return [self.figure_resource(r, try_mode) for r in query_resource.values()] - res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) else None) - res_name = query_resource.name if hasattr(query_resource, "name") else (query_resource.get("name") if isinstance(query_resource, dict) else None) + res_id = ( + query_resource.id + if hasattr(query_resource, "id") + else (query_resource.get("id") if isinstance(query_resource, dict) else None) + ) + res_name = ( + query_resource.name + if hasattr(query_resource, "name") + else (query_resource.get("name") if isinstance(query_resource, dict) else None) + ) res_identifier = res_id if res_id else res_name identifier_key = "id" if res_id else "name" resource_cls_type = type(query_resource) @@ -54,7 +69,9 @@ class DeviceNodeResourceTracker(object): ) else: res_list.extend( - self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key)) + self.loop_find_resource( + r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key) + ) ) if not try_mode: assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在" @@ -66,12 +83,16 @@ class DeviceNodeResourceTracker(object): self.resource2parent_resource[id(res_list[0][1])] = res_list[0][0] return res_list[0][1] - def loop_find_resource(self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None) -> List[Tuple[Any, Any]]: + def loop_find_resource( + self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None + ) -> List[Tuple[Any, Any]]: res_list = [] # print(resource, target_resource_cls_type, identifier_key, compare_value) children = getattr(resource, "children", []) for child in children: - res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource)) + res_list.extend( + self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource) + ) if target_resource_cls_type == type(resource): if target_resource_cls_type == dict: if identifier_key in resource: diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index b1b718f6..0203d699 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -6,7 +6,6 @@ """ import asyncio import inspect -import json import traceback from abc import abstractmethod from typing import Type, Any, Dict, Optional, TypeVar, Generic @@ -267,40 +266,45 @@ class PyLabRobotCreator(DeviceClassCreator[T]): ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(done_cb) -class ProtocolNodeCreator(DeviceClassCreator[T]): +class WorkstationNodeCreator(DeviceClassCreator[T]): """ - ProtocolNode设备类创建器 + WorkstationNode设备类创建器 - 这个类提供了针对ProtocolNode设备类的实例创建方法,处理children参数。 + 这个类提供了针对WorkstationNode设备类的实例创建方法,处理children参数。 """ def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): """ - 初始化ProtocolNode设备类创建器 + 初始化WorkstationNode设备类创建器 Args: - cls: ProtocolNode设备类 + cls: WorkstationNode设备类 children: 子资源字典,用于资源替换 """ super().__init__(cls, children, resource_tracker) def create_instance(self, data: Dict[str, Any]) -> T: """ - 从数据创建ProtocolNode设备实例 + 从数据创建WorkstationNode设备实例 Args: data: 用于创建实例的数据 Returns: - ProtocolNode设备类实例 + WorkstationNode设备类实例 """ try: # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 data["children"] = self.children - self.device_instance = super(ProtocolNodeCreator, self).create_instance(data) + station_resource_dict = data["station_resource"] + from pylabrobot.resources import Deck, Resource + plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker) + station_resource = plrc.create_instance(station_resource_dict) + data["station_resource"] = station_resource + self.device_instance = super(WorkstationNodeCreator, self).create_instance(data) self.post_create() return self.device_instance except Exception as e: - logger.error(f"ProtocolNode创建实例失败: {e}") - logger.error(f"ProtocolNode创建实例堆栈: {traceback.format_exc()}") + logger.error(f"WorkstationNode创建实例失败: {e}") + logger.error(f"WorkstationNode创建实例堆栈: {traceback.format_exc()}") raise diff --git a/unilabos_msgs/CMakeLists.txt b/unilabos_msgs/CMakeLists.txt index af5cf16b..c67bdf47 100644 --- a/unilabos_msgs/CMakeLists.txt +++ b/unilabos_msgs/CMakeLists.txt @@ -98,6 +98,19 @@ set(action_files "action/WorkStationRun.action" "action/AGVTransfer.action" + + "action/DispenStationSolnPrep.action" + "action/DispenStationVialFeed.action" + + "action/PostProcessGrab.action" + "action/PostProcessTriggerClean.action" + "action/PostProcessTriggerPostPro.action" + + "action/ReactionStationDripBack.action" + "action/ReactionStationLiquidFeed.action" + "action/ReactionStationProExecu.action" + "action/ReactionStationReaTackIn.action" + "action/ReactionStationSolidFeedVial.action" ) set(srv_files diff --git a/unilabos_msgs/action/DispenStationSolnPrep.action b/unilabos_msgs/action/DispenStationSolnPrep.action new file mode 100644 index 00000000..49afcac5 --- /dev/null +++ b/unilabos_msgs/action/DispenStationSolnPrep.action @@ -0,0 +1,15 @@ +# Goal - (二胺)溶液配置 +string order_name # 任务名称 +string material_name #固体物料名称 +string target_weigh #固体重量 +string volume #液体体积 +string liquid_material_name # 溶剂名称 +string speed #磁力转动速度 +string temperature #温度 +string delay_time #等待时间 +string hold_m_name #样品名称 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/DispenStationVialFeed.action b/unilabos_msgs/action/DispenStationVialFeed.action new file mode 100644 index 00000000..6b85058e --- /dev/null +++ b/unilabos_msgs/action/DispenStationVialFeed.action @@ -0,0 +1,29 @@ +# Goal - 小瓶投料 +string order_name # 任务名称 +string percent_90_1_assign_material_name +string percent_90_1_target_weigh +string percent_90_2_assign_material_name +string percent_90_2_target_weigh +string percent_90_3_assign_material_name +string percent_90_3_target_weigh +string percent_10_1_assign_material_name +string percent_10_1_target_weigh +string percent_10_1_volume +string percent_10_1_liquid_material_name +string percent_10_2_assign_material_name +string percent_10_2_target_weigh +string percent_10_2_volume +string percent_10_2_liquid_material_name +string percent_10_3_assign_material_name +string percent_10_3_target_weigh +string percent_10_3_volume +string percent_10_3_liquid_material_name +string speed +string temperature +string delay_time +string hold_m_name +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/PostProcessGrab.action b/unilabos_msgs/action/PostProcessGrab.action new file mode 100644 index 00000000..88da7bae --- /dev/null +++ b/unilabos_msgs/action/PostProcessGrab.action @@ -0,0 +1,8 @@ +# Goal - 抓取参数 +int32 reaction_tank_number #反应罐号码 +int32 raw_tank_number #原料罐号码 +--- +# Result - 操作结果 +string return_info +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/PostProcessTriggerClean.action b/unilabos_msgs/action/PostProcessTriggerClean.action new file mode 100644 index 00000000..7308aca7 --- /dev/null +++ b/unilabos_msgs/action/PostProcessTriggerClean.action @@ -0,0 +1,46 @@ +# Goal - 清洗参数 +float64 nmp_outer_wall_cleaning_injection +int32 nmp_outer_wall_cleaning_count +int32 nmp_outer_wall_cleaning_wait_time +int32 nmp_outer_wall_cleaning_waste_time +float64 nmp_inner_wall_cleaning_injection +int32 nmp_inner_wall_cleaning_count +int32 nmp_pump_cleaning_suction_count +int32 nmp_inner_wall_cleaning_waste_time +float64 nmp_stirrer_cleaning_injection +int32 nmp_stirrer_cleaning_count +int32 nmp_stirrer_cleaning_wait_time +int32 nmp_stirrer_cleaning_waste_time +float64 water_outer_wall_cleaning_injection +int32 water_outer_wall_cleaning_count +int32 water_outer_wall_cleaning_wait_time +int32 water_outer_wall_cleaning_waste_time +float64 water_inner_wall_cleaning_injection +int32 water_inner_wall_cleaning_count +int32 water_pump_cleaning_suction_count +int32 water_inner_wall_cleaning_waste_time +float64 water_stirrer_cleaning_injection +int32 water_stirrer_cleaning_count +int32 water_stirrer_cleaning_wait_time +int32 water_stirrer_cleaning_waste_time +float64 acetone_outer_wall_cleaning_injection +int32 acetone_outer_wall_cleaning_count +int32 acetone_outer_wall_cleaning_wait_time +int32 acetone_outer_wall_cleaning_waste_time +float64 acetone_inner_wall_cleaning_injection +int32 acetone_inner_wall_cleaning_count +int32 acetone_pump_cleaning_suction_count +int32 acetone_inner_wall_cleaning_waste_time +float64 acetone_stirrer_cleaning_injection +int32 acetone_stirrer_cleaning_count +int32 acetone_stirrer_cleaning_wait_time +int32 acetone_stirrer_cleaning_waste_time +int32 pipe_blowing_time +int32 injection_pump_forward_empty_suction_count +int32 injection_pump_reverse_empty_suction_count +int32 filtration_liquid_selection +--- +# Result - 操作结果 +string return_info # 操作是否成功 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/PostProcessTriggerPostPro.action b/unilabos_msgs/action/PostProcessTriggerPostPro.action new file mode 100644 index 00000000..a5fa0598 --- /dev/null +++ b/unilabos_msgs/action/PostProcessTriggerPostPro.action @@ -0,0 +1,20 @@ +# Goal - 后处理参数 +float64 atomization_fast_speed +float64 wash_slow_speed +int32 injection_pump_suction_speed +int32 injection_pump_push_speed +int32 raw_liquid_suction_count +float64 first_wash_water_amount +float64 second_wash_water_amount +int32 first_powder_mixing_tim +int32 second_powder_mixing_time +int32 first_powder_wash_count +int32 second_powder_wash_count +float64 initial_water_amount +int32 pre_filtration_mixing_time +int32 atomization_pressure_kpa +--- +# Result - 操作结果 +string return_info # 操作是否成功 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationDripBack.action b/unilabos_msgs/action/ReactionStationDripBack.action new file mode 100644 index 00000000..df690b3b --- /dev/null +++ b/unilabos_msgs/action/ReactionStationDripBack.action @@ -0,0 +1,11 @@ +# Goal - 滴回去 +string volume # 投料体积 +string assign_material_name # 溶剂名称 +string time # 观察时间(单位min) +string torque_variation #是否观察1否2是 +--- +# Result - 操作结果 +string return_info # 结果消息 + +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationLiquidFeed.action b/unilabos_msgs/action/ReactionStationLiquidFeed.action new file mode 100644 index 00000000..8be9dbba --- /dev/null +++ b/unilabos_msgs/action/ReactionStationLiquidFeed.action @@ -0,0 +1,11 @@ +# Goal - 液体投料 +string titration_type # 滴定类型1否2是 +string volume # 投料体积 +string assign_material_name # 溶剂名称 +string time # 观察时间(单位min) +string torque_variation #是否观察1否2是 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationProExecu.action b/unilabos_msgs/action/ReactionStationProExecu.action new file mode 100644 index 00000000..0c4649a8 --- /dev/null +++ b/unilabos_msgs/action/ReactionStationProExecu.action @@ -0,0 +1,8 @@ +# Goal - 合并工作流+执行 +string workflow_name # 工作流名称 +string task_name # 任务名称 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationReaTackIn.action b/unilabos_msgs/action/ReactionStationReaTackIn.action new file mode 100644 index 00000000..78d873ac --- /dev/null +++ b/unilabos_msgs/action/ReactionStationReaTackIn.action @@ -0,0 +1,9 @@ +# Goal - 通量-配置 +string cutoff # 黏度_通量-配置 +string temperature # 温度_通量-配置 +string assign_material_name # 分液类型_通量-配置 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationSolidFeedVial.action b/unilabos_msgs/action/ReactionStationSolidFeedVial.action new file mode 100644 index 00000000..b51096d1 --- /dev/null +++ b/unilabos_msgs/action/ReactionStationSolidFeedVial.action @@ -0,0 +1,10 @@ +# Goal - 固体投料-小瓶 +string assign_material_name # 固体名称_粉末加样模块-投料 +string material_id # 固体投料类型_粉末加样模块-投料 +string time # 观察时间_反应模块-观察搅拌结果 +string torque_variation #是否观察1否2是_反应模块-观察搅拌结果 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈