From 16ad4bbecc8197493c0d1d85e651e68931db481a Mon Sep 17 00:00:00 2001 From: dijkstra402 Date: Tue, 4 Nov 2025 02:01:44 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=A5=94=E8=80=80?= =?UTF-8?q?=E5=92=8C=E4=BE=9D=E5=8D=8E=E5=B7=A5=E7=AB=99=E7=9A=84Deck?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新奔耀YB工站deck坐标(基于图片像素精确计算) * 将粉末加样头堆栈拆分为左右两部分 * 将试剂替换仓库拆分为左右两部分 * 更新所有堆栈的坐标位置 - 更新依华扣电工站deck坐标(使用精确的像素-毫米转换) * 修正所有子弹夹的坐标位置(铝箔、正极片、正极壳等) * 更新料盘坐标(负极料盘、隔膜料盘) * 更新瓶架坐标(奔耀上料瓶架、电解液缓存位、回收位) * 更新枪头盒和废枪头盒坐标 * 确保所有坐标在deck范围内(3650×1550mm) - 转换比例说明: * 奔耀工站:deck左上角(206,446),使用1.56mm/像素 * 依华工站:deck左上角(494,444)到右下角(2430,1608) X方向:1.885mm/像素,Y方向:1.332mm/像素 --- .../coin_cell_assembly/YB_YH_materials.py | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py index 7d26a15e..adb25cfe 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -1154,31 +1154,37 @@ class CoincellDeck(Deck): def setup(self) -> None: """设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置""" # ====================================== 子弹夹 ============================================ - # 铝箔(1) + # 铝箔(1个洞位) lvbo_zip = ClipMagazine_one("lvbo_zip", 80, 80, 10) - self.assign_child_resource(lvbo_zip, Coordinate(x=1400, y=50, z=0)) - #正极(234) + self.assign_child_resource(lvbo_zip, Coordinate(x=2737.0, y=301.0, z=0)) + + # 正极片(4个洞位,2x2布局) zhengji_zip = ClipMagazine_four("zhengji_zip", 80, 80, 10) - self.assign_child_resource(zhengji_zip, Coordinate(x=1400, y=50, z=0)) - #2 正极壳 + self.assign_child_resource(zhengji_zip, Coordinate(x=2799.0, y=356.0, z=0)) + + # 正极壳(4个洞位,2x2布局) zhengjike_zip = ClipMagazine_four("zhengjike_zip", 80, 80, 10) - self.assign_child_resource(zhengjike_zip, Coordinate(x=1600, y=200, z=0)) - # 垫片 + self.assign_child_resource(zhengjike_zip, Coordinate(x=2586.0, y=1143.0, z=0)) + + # 垫片(2个洞位,1x2布局) danpian_zip = ClipMagazine_two("danpian_zip", 80, 80, 10) - self.assign_child_resource(danpian_zip, Coordinate(x=1500, y=200, z=0)) - #2 负极壳 + self.assign_child_resource(danpian_zip, Coordinate(x=2690.0, y=1141.0, z=0)) + + # 负极壳(4个洞位,2x2布局) fujike_zip = ClipMagazine_four("fujike_zip", 80, 80, 10) - self.assign_child_resource(fujike_zip, Coordinate(x=1600, y=200, z=0)) - # 弹片 - tanpian_zip = ClipMagazine_two("tantanpian_zippian", 80, 80, 10) - self.assign_child_resource(tanpian_zip, Coordinate(x=1500, y=300, z=0)) - #3成品弹夹 + self.assign_child_resource(fujike_zip, Coordinate(x=2492.0, y=1144.0, z=0)) + + # 弹片(2个洞位,1x2布局) + tanpian_zip = ClipMagazine_two("tanpian_zip", 80, 80, 10) + self.assign_child_resource(tanpian_zip, Coordinate(x=2492.0, y=1139.0, z=0)) + + # 成品弹夹(6个洞位,3x2布局) chengpindanjia_zip = ClipMagazine("chengpindanjia_zip", 80, 80, 10) - self.assign_child_resource(chengpindanjia_zip, Coordinate(x=1500, y=200, z=0)) + self.assign_child_resource(chengpindanjia_zip, Coordinate(x=3112.0, y=1295.0, z=0)) # 为子弹夹添加极片 for i in range(1): # ClipMagazine_one 有1个洞位 - lvbo = ElectrodeSheet(name=f"lvbo{i}", size_x=12, size_y=12, size_z=0.1) + lvbo = ElectrodeSheet(name=f"lvbo_{i}", size_x=12, size_y=12, size_z=0.1) lvbo_zip.children[i].assign_child_resource(lvbo, location=None) for i in range(4): # ClipMagazine_four 有4个洞位 zhengji = ElectrodeSheet(name=f"zhengji_{i}", size_x=12, size_y=12, size_z=0.1) @@ -1201,22 +1207,24 @@ class CoincellDeck(Deck): # ====================================== 物料板 ============================================ - # 创建6个4*4的物料板(料盘carrier) + # 创建物料板(料盘carrier)- 4x4布局 + # 负极料盘 fujiliaopan = MaterialPlate(name="fujiliaopan", size_x=120, size_y=100, size_z=10.0, fill=True) - self.assign_child_resource(fujiliaopan, Coordinate(x=1010, y=50, z=0)) - for i in range(8): + self.assign_child_resource(fujiliaopan, Coordinate(x=2107.0, y=304.0, z=0)) + for i in range(16): fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) fujiliaopan.children[i].assign_child_resource(fujipian, location=None) + # 隔膜料盘 gemoliaopan = MaterialPlate(name="gemoliaopan", size_x=120, size_y=100, size_z=10.0, fill=True) - self.assign_child_resource(gemoliaopan, Coordinate(x=1130, y=50, z=0)) - for i in range(8): + self.assign_child_resource(gemoliaopan, Coordinate(x=2107.0, y=146.0, z=0)) + for i in range(16): gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) gemoliaopan.children[i].assign_child_resource(gemopian, location=None) # ====================================== 瓶架、移液枪 ============================================ # 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒 - # 奔耀上料5ml分液瓶小板 + # 奔耀上料5ml分液瓶小板 - 2x4布局 bottle_rack_2x4 = BottleRack( name="bottle_rack_3x4", size_x=210.0, @@ -1227,8 +1235,9 @@ class CoincellDeck(Deck): position_spacing=35.0, orientation="vertical", ) - self.assign_child_resource(bottle_rack_2x4, Coordinate(x=100, y=200, z=0)) - # 电解液缓存位6x2 + self.assign_child_resource(bottle_rack_2x4, Coordinate(x=1542.0, y=717.0, z=0)) + + # 电解液缓存位 - 6x2布局 bottle_rack_6x2 = BottleRack( name="bottle_rack_6x2", size_x=120.0, @@ -1239,9 +1248,10 @@ class CoincellDeck(Deck): position_spacing=35.0, orientation="vertical", ) - self.assign_child_resource(bottle_rack_2x6, Coordinate(x=300, y=300, z=0)) - # 电解液回收位6x2 - bottle_rack_2x6_2 = BottleRack( + self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1659.0, y=869.0, z=0)) + + # 电解液回收位 - 6x2布局 + bottle_rack_6x2_2 = BottleRack( name="bottle_rack_6x2_2", size_x=120.0, size_y=250.0, @@ -1251,22 +1261,22 @@ class CoincellDeck(Deck): position_spacing=35.0, orientation="vertical", ) - self.assign_child_resource(bottle_rack_2x6_2, Coordinate(x=430, y=300, z=0)) + self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=1765.0, y=869.0, z=0)) # 将 ElectrodeSheet 放满 3x4 与 6x2 的所有孔位 for idx in range(bottle_rack_2x4.num_items_x * bottle_rack_2x4.num_items_y): sheet = ElectrodeSheet(name=f"sheet_3x4_{idx}", size_x=12, size_y=12, size_z=0.1) bottle_rack_2x4.assign_child_resource(sheet, index=idx) - for idx in range(bottle_rack_2x6.num_items_x * bottle_rack_2x6.num_items_y): + for idx in range(bottle_rack_6x2.num_items_x * bottle_rack_6x2.num_items_y): sheet = ElectrodeSheet(name=f"sheet_6x2_{idx}", size_x=12, size_y=12, size_z=0.1) - bottle_rack_2x6.assign_child_resource(sheet, index=idx) + bottle_rack_6x2.assign_child_resource(sheet, index=idx) tip_box = TipBox64(name="tip_box_64") - self.assign_child_resource(tip_box, Coordinate(x=300, y=100, z=0)) + self.assign_child_resource(tip_box, Coordinate(x=1938.0, y=743.0, z=0)) waste_tip_box = WasteTipBox(name="waste_tip_box") - self.assign_child_resource(waste_tip_box, Coordinate(x=300, y=200, z=0)) + self.assign_child_resource(waste_tip_box, Coordinate(x=1960.0, y=639.0, z=0)) From 3c583008aa6684a58bb54729d44a0b724ec5b518 Mon Sep 17 00:00:00 2001 From: lixinyu1011 <674842481@qq.com> Date: Tue, 4 Nov 2025 20:19:27 +0800 Subject: [PATCH 02/15] YB4branc_bylixinyu --- new_cellconfig3c.json | 39 ++++++++++++-- .../coin_cell_assembly/coin_cell_assembly.py | 54 +++++-------------- unilabos/registry/resources/bioyond/deck.yaml | 15 ++++++ 3 files changed, 63 insertions(+), 45 deletions(-) diff --git a/new_cellconfig3c.json b/new_cellconfig3c.json index 446d2357..2a5d6ebc 100644 --- a/new_cellconfig3c.json +++ b/new_cellconfig3c.json @@ -47,21 +47,50 @@ { "id": "BatteryStation", "name": "扣电工作站", + "parent": null, "children": [ "coin_cell_deck" ], - "parent": null, "type": "device", - "class": "coincellassemblyworkstation_device", + "class":"coincellassemblyworkstation_device", "position": { - "x": 600, - "y": 400, + "x": -600, + "y": -400, "z": 0 }, "config": { - "debug_mode": false, + "deck": { + "data": { + "_resource_child_name": "YB_YH_Deck", + "_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck" + } + }, "protocol_type": [] } + }, + { + "id": "YB_YH_Deck", + "name": "YB_YH_Deck", + "children": [], + "parent": "BatteryStation", + "type": "deck", + "class": "CoincellDeck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "CoincellDeck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} } ], "links": [] diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 65d18ba7..21682a9e 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -109,44 +109,23 @@ def _coerce_deck_input(deck: Any) -> Optional[Deck]: #构建物料系统 class CoinCellAssemblyWorkstation(WorkstationBase): - def __init__( - self, - deck: Deck=None, - address: str = "172.21.33.176", - port: str = "502", - debug_mode: bool = False, + def __init__(self, + config: dict = None, + deck=None, + address: str = "172.21.33.176", + port: str = "502", + debug_mode: bool = False, *args, - **kwargs, - ): - if deck is None and "deck" in kwargs: - deck = kwargs.pop("deck") - else: - kwargs.pop("deck", None) + **kwargs): - normalized_deck = _coerce_deck_input(deck) - - if deck is None and isinstance(normalized_deck, Deck): - deck = normalized_deck - - super().__init__( - #桌子 - deck=deck, - *args, - **kwargs, - ) + if deck is None and config: + deck = config.get('deck') + else : + logger.info("没有传入依华deck,检查启动json文件") + super().__init__(deck=deck, *args, **kwargs,) self.debug_mode = debug_mode - # 如果没有传入 deck,则创建标准配置的 deck - if self.deck is None: - self.deck = CoincellDeck(size_x=3650, size_y=1550, size_z=2100, origin=Coordinate(-2000, 100, 0),setup=True) - else: - # 如果传入了 deck 但还没有 setup,可以选择是否 setup - if self.deck is not None and len(self.deck.children) == 0: - # deck 为空,执行 setup - self.deck.setup() - # 否则使用传入的 deck(可能已经配置好了) - self.deck = self.deck - + """ 连接初始化 """ modbus_client = TCPClient(addr=address, port=port) logger.debug(f"创建 Modbus 客户端: {modbus_client}") @@ -173,12 +152,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase): self.csv_export_running = False self.csv_export_file = None self.coin_num_N = 0 #已组装电池数量 - #创建一个物料台面,包含两个极片板 - #self._ros_node.update_resource(self.deck) - - #ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - # "resources": [self.deck] - #}) + def post_init(self, ros_node: ROS2WorkstationNode): diff --git a/unilabos/registry/resources/bioyond/deck.yaml b/unilabos/registry/resources/bioyond/deck.yaml index 664bab3e..d28218b4 100644 --- a/unilabos/registry/resources/bioyond/deck.yaml +++ b/unilabos/registry/resources/bioyond/deck.yaml @@ -34,3 +34,18 @@ BIOYOND_YB_Deck: init_param_schema: {} registry_type: resource version: 1.0.0 +CoincellDeck: + category: + - deck + class: + module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck + type: pylabrobot + description: CoincellDeck + handles: [] + icon: yihua.webp + init_param_schema: {} + registry_type: resource + version: 1.0.0 + + + From 966b51042dd443a811b28e3329cf9420c60c551e Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 6 Nov 2025 00:59:46 +0800 Subject: [PATCH 03/15] =?UTF-8?q?rename=20and=20fix=20all=20Yihua=20Materi?= =?UTF-8?q?als:=20ClipMagazineHole=E2=86=92Magazine(ResourceStack),=20and?= =?UTF-8?q?=20use=20factory=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bioyond_yihua_YB.json | 104 +-- new_cellconfig.json | 54 ++ .../bioyond_cell/bioyond_cell_workstation.py | 10 +- .../bioyond_cell/bioyond_yihua_YB.json | 104 +-- .../coin_cell_assembly/YB_YH_materials.py | 736 +++--------------- .../coin_cell_assembly/coin_cell_assembly.py | 17 +- .../workstation_material_management.py | 583 -------------- unilabos/registry/resources/bioyond/deck.yaml | 3 - unilabos/resources/battery/bottle_carriers.py | 56 ++ unilabos/resources/battery/magazine.py | 284 +++++++ unilabos/resources/itemized_carrier.py | 2 +- 11 files changed, 602 insertions(+), 1351 deletions(-) create mode 100644 new_cellconfig.json delete mode 100644 unilabos/devices/workstation/workstation_material_management.py create mode 100644 unilabos/resources/battery/bottle_carriers.py create mode 100644 unilabos/resources/battery/magazine.py diff --git a/bioyond_yihua_YB.json b/bioyond_yihua_YB.json index c38179d9..668ad6a2 100644 --- a/bioyond_yihua_YB.json +++ b/bioyond_yihua_YB.json @@ -99,7 +99,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine_four", + "type": "MagazineHolder_4", "size_x": 80, "size_y": 80, "size_z": 10, @@ -140,7 +140,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -235,7 +235,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -330,7 +330,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -425,7 +425,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -523,7 +523,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine_four", + "type": "MagazineHolder_4", "size_x": 80, "size_y": 80, "size_z": 10, @@ -564,7 +564,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -659,7 +659,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -754,7 +754,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -849,7 +849,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -949,7 +949,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -992,7 +992,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1087,7 +1087,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1182,7 +1182,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1277,7 +1277,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1372,7 +1372,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1467,7 +1467,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1567,7 +1567,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -1610,7 +1610,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1705,7 +1705,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1800,7 +1800,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1895,7 +1895,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1990,7 +1990,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2085,7 +2085,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2185,7 +2185,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -2228,7 +2228,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2323,7 +2323,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2418,7 +2418,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2513,7 +2513,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2608,7 +2608,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2703,7 +2703,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2803,7 +2803,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -2846,7 +2846,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2941,7 +2941,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3036,7 +3036,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3131,7 +3131,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3226,7 +3226,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3321,7 +3321,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3421,7 +3421,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -3464,7 +3464,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3559,7 +3559,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3654,7 +3654,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3749,7 +3749,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3844,7 +3844,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3939,7 +3939,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4039,7 +4039,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -4082,7 +4082,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4177,7 +4177,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4272,7 +4272,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4367,7 +4367,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4462,7 +4462,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4557,7 +4557,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, diff --git a/new_cellconfig.json b/new_cellconfig.json new file mode 100644 index 00000000..d06fd0eb --- /dev/null +++ b/new_cellconfig.json @@ -0,0 +1,54 @@ +{ + "nodes": [ + { + "id": "BatteryStation", + "name": "扣电工作站", + "parent": null, + "children": [ + "coin_cell_deck" + ], + "type": "device", + "class":"coincellassemblyworkstation_device", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "deck": { + "data": { + "_resource_child_name": "YB_YH_Deck", + "_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck" + } + }, + "debug_mode": true, + "protocol_type": [] + } + }, + { + "id": "YB_YH_Deck", + "name": "YB_YH_Deck", + "children": [], + "parent": "BatteryStation", + "type": "deck", + "class": "CoincellDeck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "CoincellDeck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index ee825791..a6722306 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -16,6 +16,7 @@ from unilabos.devices.workstation.bioyond_studio.config import ( API_CONFIG, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, SOLID_LIQUID_MAPPINGS ) from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService +from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck from unilabos.utils.log import logger from unilabos.registry.registry import lab_registry @@ -1074,17 +1075,12 @@ class BioyondCellWorkstation(BioyondWorkstation): if __name__ == "__main__": lab_registry.setup() - ws = BioyondCellWorkstation() + deck = BIOYOND_YB_Deck(setup=True) + ws = BioyondCellWorkstation(deck=deck) # ws.create_sample(name="test", board_type="配液瓶(小)板", bottle_type="配液瓶(小)", location_code="B01") # logger.info(ws.scheduler_stop()) # logger.info(ws.scheduler_start()) - # results = ws.create_materials(SOLID_LIQUID_MAPPINGS) - # for r in results: - # logger.info(r) - # 从CSV文件读取物料列表并批量创建入库 - # result = ws.create_and_inbound_materials() - # 继续后续流程 # logger.info(ws.auto_feeding4to3()) #搬运物料到3号箱 # # # 使用正斜杠或 Path 对象来指定文件路径 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json index 3d1b98af..3119d0bf 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json @@ -113,7 +113,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine_four", + "type": "MagazineHolder_4", "size_x": 80, "size_y": 80, "size_z": 10, @@ -154,7 +154,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -249,7 +249,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -344,7 +344,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -439,7 +439,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -537,7 +537,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine_four", + "type": "MagazineHolder_4", "size_x": 80, "size_y": 80, "size_z": 10, @@ -578,7 +578,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -673,7 +673,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -768,7 +768,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -863,7 +863,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -963,7 +963,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -1006,7 +1006,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1101,7 +1101,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1196,7 +1196,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1291,7 +1291,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1386,7 +1386,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1481,7 +1481,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1581,7 +1581,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -1624,7 +1624,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1719,7 +1719,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1814,7 +1814,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1909,7 +1909,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2004,7 +2004,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2099,7 +2099,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2199,7 +2199,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -2242,7 +2242,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2337,7 +2337,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2432,7 +2432,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2527,7 +2527,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2622,7 +2622,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2717,7 +2717,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2817,7 +2817,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -2860,7 +2860,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2955,7 +2955,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3050,7 +3050,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3145,7 +3145,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3240,7 +3240,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3335,7 +3335,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3435,7 +3435,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -3478,7 +3478,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3573,7 +3573,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3668,7 +3668,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3763,7 +3763,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3858,7 +3858,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3953,7 +3953,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4053,7 +4053,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -4096,7 +4096,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4191,7 +4191,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4286,7 +4286,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4381,7 +4381,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4476,7 +4476,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4571,7 +4571,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py index 156ab6f8..8bb0a8de 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -18,6 +18,9 @@ from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash from pylabrobot.resources.utils import create_ordered_items_2d +from unilabos.resources.battery.magazine import MagazineHolder_1, MagazineHolder_2, MagazineHolder_4, MagazineHolder_6 +from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier + class ElectrodeSheetState(TypedDict): diameter: float # 直径 (mm) @@ -165,7 +168,6 @@ class MaterialHole(Resource): return self.children[index] - class MaterialPlateState(TypedDict): hole_spacing_x: float hole_spacing_y: float @@ -327,132 +329,6 @@ class PlateSlot(ResourceStack): } -class ClipMagazineHole(Container): - """子弹夹洞位类""" - - def __init__( - self, - name: str, - diameter: float, - depth: float, - max_sheets: int = 100, - category: str = "clip_magazine_hole", - ): - """初始化子弹夹洞位 - - Args: - name: 洞位名称 - diameter: 洞直径 (mm) - depth: 洞深度 (mm) - max_sheets: 最大极片数量 - category: 类别 - """ - super().__init__( - name=name, - size_x=diameter, - size_y=diameter, - size_z=depth, - category=category, - ) - self.diameter = diameter - self.depth = depth - self.max_sheets = max_sheets - self._sheets: List[ElectrodeSheet] = [] - - def can_add_sheet(self, sheet: ElectrodeSheet) -> bool: - """检查是否可以添加极片""" - return (len(self._sheets) < self.max_sheets and - sheet.diameter <= self.diameter) - - def add_sheet(self, sheet: ElectrodeSheet) -> None: - """添加极片""" - if not self.can_add_sheet(sheet): - raise ValueError(f"无法向洞位 {self.name} 添加极片") - self._sheets.append(sheet) - - def take_sheet(self) -> ElectrodeSheet: - """取出极片""" - if len(self._sheets) == 0: - raise ValueError(f"洞位 {self.name} 没有极片") - return self._sheets.pop() - - def get_sheet_count(self) -> int: - """获取极片数量""" - return len(self._sheets) - - def serialize_state(self) -> Dict[str, Any]: - return { - "sheet_count": len(self._sheets), - "sheets": [sheet.serialize() for sheet in self._sheets], - } - -# TODO: 这个要改 -class ClipMagazine(ItemizedResource[ClipMagazineHole]): - """子弹夹类 - 有6个洞位,每个洞位放多个极片""" - children: List[ClipMagazineHole] - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, - category: str = "clip_magazine", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing: 洞位间距 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - # 创建6个洞位,排成2x3布局 - holes = create_ordered_items_2d( - klass=ClipMagazineHole, - num_items_x=3, - num_items_y=2, - dx=(size_x - 2 * hole_spacing) / 2, # 居中 - dy=(size_y - hole_spacing) / 2, # 居中 - dz=size_z - 0, - item_dx=hole_spacing, - item_dy=hole_spacing, - diameter=hole_diameter, - depth=hole_depth, - ) - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - } #是一种类型注解,不用self class BatteryState(TypedDict): """电池状态字典""" @@ -595,76 +471,54 @@ class BatteryPressSlot(Resource): def get_battery_info(self, index: int) -> Battery: return self.children[0] -# TODO:这个移液枪架子看一下从哪继承 -class TipBox64State(TypedDict): - """电池状态字典""" - tip_diameter: float = 5.0 - tip_length: float = 50.0 - with_tips: bool = True -class TipBox64(TipRack): - """64孔枪头盒类""" - - children: List[TipSpot] = [] - def __init__( - self, +def TipBox64( name: str, size_x: float = 127.8, size_y: float = 85.5, size_z: float = 60.0, category: str = "tip_box_64", model: Optional[str] = None, - ): - """初始化64孔枪头盒 +): + """64孔枪头盒类""" + from pylabrobot.resources.tip import Tip - Args: - name: 枪头盒名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - tip_diameter: 枪头直径 (mm) - tip_length: 枪头长度 (mm) - category: 类别 - model: 型号 - with_tips: 是否带枪头 - """ - from pylabrobot.resources.tip import Tip - - # 创建8x8=64个枪头位 - def make_tip(): - return Tip( - has_filter=False, - total_tip_length=20.0, - maximal_volume=1000, # 1mL - fitting_depth=8.0, - ) - - tip_spots = create_ordered_items_2d( - klass=TipSpot, - num_items_x=8, - num_items_y=8, - dx=8.0, - dy=8.0, - dz=0.0, - item_dx=9.0, - item_dy=9.0, - size_x=10, - size_y=10, - size_z=0.0, - make_tip=make_tip, - ) - self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate() - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=tip_spots, - category=category, - model=model, - with_tips=True, + # 创建8x8=64个枪头位 + def make_tip(): + return Tip( + has_filter=False, + total_tip_length=20.0, + maximal_volume=1000, # 1mL + fitting_depth=8.0, ) + tip_spots = create_ordered_items_2d( + klass=TipSpot, + num_items_x=12, + num_items_y=8, + dx=8.0, + dy=8.0, + dz=0.0, + item_dx=9.0, + item_dy=9.0, + size_x=10, + size_y=10, + size_z=0.0, + make_tip=make_tip, + ) + idx_available = list(range(0, 32)) + list(range(64, 96)) + tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available} + return TipRack( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=tip_spots_available, + category=category, + model=model, + with_tips=True, + ) + class WasteTipBoxstate(TypedDict): @@ -682,8 +536,12 @@ class WasteTipBox(Trash): size_x: float = 127.8, size_y: float = 85.5, size_z: float = 60.0, - category: str = "waste_tip_box", - model: Optional[str] = None, + material_z_thickness=0, + max_volume=float("inf"), + category="trash", + model=None, + compute_volume_from_height=None, + compute_height_from_volume=None, ): """初始化废枪头盒 @@ -733,389 +591,6 @@ class WasteTipBox(Trash): return data -class BottleRackState(TypedDict): - """ bottle_diameter: 瓶子直径 (mm) - bottle_height: 瓶子高度 (mm) - position_spacing: 位置间距 (mm)""" - bottle_diameter: float - bottle_height: float - name_to_index: dict - - - -class BottleRack(Resource): - """瓶架类 - 12个待配位置+12个已配位置""" - children: List[Resource] = [] - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - category: str = "bottle_rack", - model: Optional[str] = None, - num_items_x: int = 2, - num_items_y: int = 4, - position_spacing: float = 35.0, - orientation: str = "horizontal", - padding_x: float = 20.0, - padding_y: float = 20.0, - ): - """初始化瓶架 - - Args: - name: 瓶架名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - # 初始化状态 - self._unilabos_state: BottleRackState = BottleRackState( - bottle_diameter=30.0, - bottle_height=100.0, - position_spacing=position_spacing, - name_to_index={}, - ) - # 基于网格生成瓶位坐标映射(居中摆放) - # 使用内边距,避免点跑到容器外(前端渲染不按mm等比缩放时更稳妥) - origin_x = padding_x - origin_y = padding_y - self.index_to_pos = {} - for j in range(num_items_y): - for i in range(num_items_x): - idx = j * num_items_x + i - if orientation == "vertical": - # 纵向:沿 y 方向优先排列 - self.index_to_pos[idx] = Coordinate( - x=origin_x + j * position_spacing, - y=origin_y + i * position_spacing, - z=0, - ) - else: - # 横向(默认):沿 x 方向优先排列 - self.index_to_pos[idx] = Coordinate( - x=origin_x + i * position_spacing, - y=origin_y + j * position_spacing, - z=0, - ) - self.name_to_index = {} - self.name_to_pos = {} - self.num_items_x = num_items_x - self.num_items_y = num_items_y - self.orientation = orientation - self.padding_x = padding_x - self.padding_y = padding_y - - 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 - - # TODO: 这里有些问题要重新写一下 - def assign_child_resource_old(self, resource: Resource, location=Coordinate.zero(), reassign=True): - capacity = self.num_items_x * self.num_items_y - assert len(self.children) < capacity, "瓶架已满,无法添加更多瓶子" - index = len(self.children) - location = self.index_to_pos.get(index, Coordinate.zero()) - self.name_to_pos[resource.name] = location - self.name_to_index[resource.name] = index - return super().assign_child_resource(resource, location, reassign) - - def assign_child_resource(self, resource: Resource, index: int): - capacity = self.num_items_x * self.num_items_y - assert 0 <= index < capacity, "无效的瓶子索引" - self.name_to_index[resource.name] = index - location = self.index_to_pos[index] - return super().assign_child_resource(resource, location) - - def unassign_child_resource(self, resource: Bottle): - super().unassign_child_resource(resource) - self.index_to_pos.pop(self.name_to_index.pop(resource.name, None), None) - - def serialize(self) -> dict: - return { - **super().serialize(), - "num_items_x": self.num_items_x, - "num_items_y": self.num_items_y, - "position_spacing": self._unilabos_state.get("position_spacing", 35.0), - "orientation": self.orientation, - "padding_x": self.padding_x, - "padding_y": self.padding_y, - } - -class BottleState(TypedDict): - diameter: float - height: float - electrolyte_name: str - electrolyte_volume: float - max_volume: float - -class Bottle(Resource): - """瓶子类 - 容纳电解液""" - - def __init__( - self, - name: str, - category: str = "bottle", - ): - """初始化瓶子 - - Args: - name: 瓶子名称 - diameter: 直径 (mm) - height: 高度 (mm) - max_volume: 最大体积 (μL) - barcode: 二维码 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=1, - size_y=1, - size_z=1, - category=category, - ) - self._unilabos_state: BottleState = BottleState() - - def aspirate_electrolyte(self, volume: float) -> bool: - current_volume = self._unilabos_state["electrolyte_volume"] - assert current_volume > volume, f"Cannot aspirate {volume}μL, only {current_volume}μL available." - self._unilabos_state["electrolyte_volume"] -= volume - return True - - 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 - -class ClipMagazine_four(ItemizedResource[ClipMagazineHole]): - """子弹夹类 - 有4个洞位,每个洞位放多个极片""" - children: List[ClipMagazineHole] - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, - category: str = "clip_magazine_four", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing: 洞位间距 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - # 创建4个洞位,排成2x2布局 - holes = create_ordered_items_2d( - klass=ClipMagazineHole, - num_items_x=2, - num_items_y=2, - dx=(size_x - 2 * hole_spacing) / 2, # 居中 - dy=(size_y - hole_spacing) / 2, # 居中 - dz=size_z - 0, - item_dx=hole_spacing, - item_dy=hole_spacing, - diameter=hole_diameter, - depth=hole_depth, - ) - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - } - -class ClipMagazine_two(ItemizedResource[ClipMagazineHole]): - """子弹夹类 - 有2个洞位,每个洞位放多个极片""" - children: List[ClipMagazineHole] - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, - category: str = "clip_magazine_four", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing: 洞位间距 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - # 创建4个洞位,排成2x2布局 - holes = create_ordered_items_2d( - klass=ClipMagazineHole, - num_items_x=1, - num_items_y=2, - dx=(size_x - 2 * hole_spacing) / 2, # 居中 - dy=(size_y - hole_spacing) / 2, # 居中 - dz=size_z - 0, - item_dx=hole_spacing, - item_dy=hole_spacing, - diameter=hole_diameter, - depth=hole_depth, - ) - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - } -class ClipMagazine_one(ItemizedResource[ClipMagazineHole]): - """子弹夹类 - 有1个洞位,每个洞位放多个极片""" - children: List[ClipMagazineHole] - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, - category: str = "clip_magazine_four", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing: 洞位间距 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - # 创建4个洞位,排成2x2布局 - holes = create_ordered_items_2d( - klass=ClipMagazineHole, - num_items_x=1, - num_items_y=1, - dx=(size_x - 2 * hole_spacing) / 2, # 居中 - dy=(size_y - hole_spacing) / 2, # 居中 - dz=size_z - 0, - item_dx=hole_spacing, - item_dy=hole_spacing, - diameter=hole_diameter, - depth=hole_depth, - ) - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - } - class CoincellDeck(Deck): """纽扣电池组装工作站台面类""" @@ -1155,122 +630,96 @@ class CoincellDeck(Deck): """设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置""" # ====================================== 子弹夹 ============================================ # 铝箔(1个洞位) - lvbo_zip = ClipMagazine_one("lvbo_zip", 80, 80, 10) + lvbo_zip = MagazineHolder_1("铝箔弹夹", 80, 80, 10) self.assign_child_resource(lvbo_zip, Coordinate(x=2737.0, y=301.0, z=0)) # 正极片(4个洞位,2x2布局) - zhengji_zip = ClipMagazine_four("zhengji_zip", 80, 80, 10) + zhengji_zip = MagazineHolder_4("正极弹夹", 80, 80, 10) self.assign_child_resource(zhengji_zip, Coordinate(x=2799.0, y=356.0, z=0)) # 正极壳(4个洞位,2x2布局) - zhengjike_zip = ClipMagazine_four("zhengjike_zip", 80, 80, 10) + zhengjike_zip = MagazineHolder_4("正极壳弹夹", 80, 80, 10) self.assign_child_resource(zhengjike_zip, Coordinate(x=2586.0, y=1143.0, z=0)) # 垫片(2个洞位,1x2布局) - danpian_zip = ClipMagazine_two("danpian_zip", 80, 80, 10) + danpian_zip = MagazineHolder_2("垫片弹夹", 80, 80, 10) self.assign_child_resource(danpian_zip, Coordinate(x=2690.0, y=1141.0, z=0)) # 负极壳(4个洞位,2x2布局) - fujike_zip = ClipMagazine_four("fujike_zip", 80, 80, 10) + fujike_zip = MagazineHolder_4("负极壳弹夹", 80, 80, 10) self.assign_child_resource(fujike_zip, Coordinate(x=2492.0, y=1144.0, z=0)) # 弹片(2个洞位,1x2布局) - tanpian_zip = ClipMagazine_two("tanpian_zip", 80, 80, 10) + tanpian_zip = MagazineHolder_2("弹片弹夹", 80, 80, 10) self.assign_child_resource(tanpian_zip, Coordinate(x=2492.0, y=1139.0, z=0)) # 成品弹夹(6个洞位,3x2布局) - chengpindanjia_zip = ClipMagazine("chengpindanjia_zip", 80, 80, 10) + chengpindanjia_zip = MagazineHolder_6("成品弹夹", 80, 80, 10) self.assign_child_resource(chengpindanjia_zip, Coordinate(x=3112.0, y=1295.0, z=0)) # 为子弹夹添加极片 - for i in range(1): # ClipMagazine_one 有1个洞位 - lvbo = ElectrodeSheet(name=f"lvbo_{i}", size_x=12, size_y=12, size_z=0.1) + for i in range(1): # MagazineHolder_1 有1个洞位 + lvbo = ElectrodeSheet(name=f"铝箔_{i}", size_x=12, size_y=12, size_z=0.1) lvbo_zip.children[i].assign_child_resource(lvbo, location=None) - for i in range(4): # ClipMagazine_four 有4个洞位 - zhengji = ElectrodeSheet(name=f"zhengji_{i}", size_x=12, size_y=12, size_z=0.1) + for i in range(4): # MagazineHolder_4 有4个洞位 + zhengji = ElectrodeSheet(name=f"正极_{i}", size_x=12, size_y=12, size_z=0.1) zhengji_zip.children[i].assign_child_resource(zhengji, location=None) - for i in range(4): # ClipMagazine_four 有4个洞位 - zhengjike = ElectrodeSheet(name=f"zhengjike_{i}", size_x=12, size_y=12, size_z=0.1) + for i in range(4): # MagazineHolder_4 有4个洞位 + zhengjike = ElectrodeSheet(name=f"正极壳_{i}", size_x=12, size_y=12, size_z=0.1) zhengjike_zip.children[i].assign_child_resource(zhengjike, location=None) - for i in range(2): # ClipMagazine_two 有2个洞位 - danpian = ElectrodeSheet(name=f"danpian_{i}", size_x=12, size_y=12, size_z=0.1) + for i in range(2): # MagazineHolder_2 有2个洞位 + danpian = ElectrodeSheet(name=f"垫片_{i}", size_x=12, size_y=12, size_z=0.1) danpian_zip.children[i].assign_child_resource(danpian, location=None) - for i in range(4): # ClipMagazine_four 有4个洞位 - fujike = ElectrodeSheet(name=f"fujike_{i}", size_x=12, size_y=12, size_z=0.1) + for i in range(4): # MagazineHolder_4 有4个洞位 + fujike = ElectrodeSheet(name=f"负极壳_{i}", size_x=12, size_y=12, size_z=0.1) fujike_zip.children[i].assign_child_resource(fujike, location=None) - for i in range(2): # ClipMagazine_two 有2个洞位 - tanpian = ElectrodeSheet(name=f"tanpian_{i}", size_x=12, size_y=12, size_z=0.1) + for i in range(2): # MagazineHolder_2 有2个洞位 + tanpian = ElectrodeSheet(name=f"弹片_{i}", size_x=12, size_y=12, size_z=0.1) tanpian_zip.children[i].assign_child_resource(tanpian, location=None) - for i in range(6): # ClipMagazine 有6个洞位 - chengpindanjia = ElectrodeSheet(name=f"chengpindanjia_{i}", size_x=12, size_y=12, size_z=0.1) - chengpindanjia_zip.children[i].assign_child_resource(chengpindanjia, location=None) + # for i in range(6): # MagazineHolder_6 有6个洞位 + # chengpindanjia = ElectrodeSheet(name=f"成品弹夹_{i}", size_x=12, size_y=12, size_z=0.1) + # chengpindanjia_zip.children[i].assign_child_resource(chengpindanjia, location=None) # ====================================== 物料板 ============================================ # 创建物料板(料盘carrier)- 4x4布局 # 负极料盘 - fujiliaopan = MaterialPlate(name="fujiliaopan", size_x=120, size_y=100, size_z=10.0, fill=True) + fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True) self.assign_child_resource(fujiliaopan, Coordinate(x=2107.0, y=304.0, z=0)) - for i in range(16): - fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - fujiliaopan.children[i].assign_child_resource(fujipian, location=None) + # for i in range(16): + # fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) + # fujiliaopan.children[i].assign_child_resource(fujipian, location=None) # 隔膜料盘 - gemoliaopan = MaterialPlate(name="gemoliaopan", size_x=120, size_y=100, size_z=10.0, fill=True) + gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True) self.assign_child_resource(gemoliaopan, Coordinate(x=2107.0, y=146.0, z=0)) - for i in range(16): - gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - gemoliaopan.children[i].assign_child_resource(gemopian, location=None) + # for i in range(16): + # gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) + # gemoliaopan.children[i].assign_child_resource(gemopian, location=None) # ====================================== 瓶架、移液枪 ============================================ # 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒 - # 奔耀上料5ml分液瓶小板 - 2x4布局 - bottle_rack_2x4 = BottleRack( - name="bottle_rack_3x4", - size_x=210.0, - size_y=140.0, - size_z=100.0, - num_items_x=2, - num_items_y=4, - position_spacing=35.0, - orientation="vertical", - ) - self.assign_child_resource(bottle_rack_2x4, Coordinate(x=1542.0, y=717.0, z=0)) + # 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写 + + # bottle_rack_3x4 = BottleRack( + # name="bottle_rack_3x4", + # size_x=210.0, + # size_y=140.0, + # size_z=100.0, + # num_items_x=2, + # num_items_y=4, + # position_spacing=35.0, + # orientation="vertical", + # ) + # self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0)) # 电解液缓存位 - 6x2布局 - bottle_rack_6x2 = BottleRack( - name="bottle_rack_6x2", - size_x=120.0, - size_y=250.0, - size_z=100.0, - num_items_x=6, - num_items_y=2, - position_spacing=35.0, - orientation="vertical", - ) + bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2") self.assign_child_resource(bottle_rack_6x2, Coordinate(x=300, y=300, z=0)) # 电解液回收位6x2 - bottle_rack_2x6_2 = BottleRack( - name="bottle_rack_6x2_2", - size_x=120.0, - size_y=250.0, - size_z=100.0, - num_items_x=6, - num_items_y=2, - position_spacing=35.0, - orientation="vertical", - ) + bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2") self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=1765.0, y=869.0, z=0)) - # 将 ElectrodeSheet 放满 3x4 与 6x2 的所有孔位 - for idx in range(bottle_rack_2x4.num_items_x * bottle_rack_2x4.num_items_y): - sheet = ElectrodeSheet(name=f"sheet_3x4_{idx}", size_x=12, size_y=12, size_z=0.1) - bottle_rack_2x4.assign_child_resource(sheet, index=idx) - - for idx in range(bottle_rack_6x2.num_items_x * bottle_rack_6x2.num_items_y): - sheet = ElectrodeSheet(name=f"sheet_6x2_{idx}", size_x=12, size_y=12, size_z=0.1) - bottle_rack_6x2.assign_child_resource(sheet, index=idx) - tip_box = TipBox64(name="tip_box_64") self.assign_child_resource(tip_box, Coordinate(x=1938.0, y=743.0, z=0)) @@ -1278,7 +727,6 @@ class CoincellDeck(Deck): self.assign_child_resource(waste_tip_box, Coordinate(x=1960.0, y=639.0, z=0)) - if __name__ == "__main__": deck = CoincellDeck(setup=True) print(deck) \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 21682a9e..f7f8e33b 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -112,19 +112,18 @@ class CoinCellAssemblyWorkstation(WorkstationBase): def __init__(self, config: dict = None, deck=None, - address: str = "172.21.33.176", - port: str = "502", - debug_mode: bool = False, + address: str = "172.21.33.176", + port: str = "502", + debug_mode: bool = False, *args, **kwargs): if deck is None and config: deck = config.get('deck') - else : + if deck is None: logger.info("没有传入依华deck,检查启动json文件") super().__init__(deck=deck, *args, **kwargs,) self.debug_mode = debug_mode - """ 连接初始化 """ modbus_client = TCPClient(addr=address, port=port) @@ -140,12 +139,14 @@ class CoinCellAssemblyWorkstation(WorkstationBase): time.sleep(2) if not modbus_client.client.is_socket_open(): raise ValueError('modbus tcp connection failed') + self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv')) + self.client = modbus_client.register_node_list(self.nodes) else: print("测试模式,跳过连接") + self.nodes, self.client = None, None """ 工站的配置 """ - self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv')) - self.client = modbus_client.register_node_list(self.nodes) + self.success = False self.allow_data_read = False #允许读取函数运行标志位 self.csv_export_thread = None @@ -153,8 +154,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase): self.csv_export_file = None self.coin_num_N = 0 #已组装电池数量 - - def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node #self.deck = create_a_coin_cell_deck() diff --git a/unilabos/devices/workstation/workstation_material_management.py b/unilabos/devices/workstation/workstation_material_management.py deleted file mode 100644 index a9229130..00000000 --- a/unilabos/devices/workstation/workstation_material_management.py +++ /dev/null @@ -1,583 +0,0 @@ -""" -工作站物料管理基类 -Workstation Material Management Base Class - -基于PyLabRobot的物料管理系统 -""" -from typing import Dict, Any, List, Optional, Union, Type -from abc import ABC, abstractmethod -import json - -from pylabrobot.resources import ( - Resource as PLRResource, - Container, - Deck, - Coordinate as PLRCoordinate, -) - -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker -from unilabos.utils.log import logger -from unilabos.resources.graphio import resource_plr_to_ulab, resource_ulab_to_plr - - -class MaterialManagementBase(ABC): - """物料管理基类 - - 定义工作站物料管理的标准接口: - 1. 物料初始化 - 根据配置创建物料资源 - 2. 物料追踪 - 实时跟踪物料位置和状态 - 3. 物料查找 - 按类型、位置、状态查找物料 - 4. 物料转换 - PyLabRobot与UniLab资源格式转换 - """ - - def __init__( - self, - device_id: str, - deck_config: Dict[str, Any], - resource_tracker: DeviceNodeResourceTracker, - children_config: Dict[str, Dict[str, Any]] = None - ): - self.device_id = device_id - self.deck_config = deck_config - self.resource_tracker = resource_tracker - self.children_config = children_config or {} - - # 创建主台面 - self.plr_deck = self._create_deck() - - # 扩展ResourceTracker - self._extend_resource_tracker() - - # 注册deck到resource tracker - self.resource_tracker.add_resource(self.plr_deck) - - # 初始化子资源 - self.plr_resources = {} - self._initialize_materials() - - def _create_deck(self) -> Deck: - """创建主台面""" - return Deck( - name=f"{self.device_id}_deck", - size_x=self.deck_config.get("size_x", 1000.0), - size_y=self.deck_config.get("size_y", 1000.0), - size_z=self.deck_config.get("size_z", 500.0), - origin=PLRCoordinate(0, 0, 0) - ) - - def _extend_resource_tracker(self): - """扩展ResourceTracker以支持PyLabRobot特定功能""" - - def find_by_type(resource_type): - """按类型查找资源""" - return self._find_resources_by_type_recursive(self.plr_deck, resource_type) - - def find_by_category(category: str): - """按类别查找资源""" - found = [] - for resource in self._get_all_resources(): - if hasattr(resource, 'category') and resource.category == category: - found.append(resource) - return found - - def find_by_name_pattern(pattern: str): - """按名称模式查找资源""" - import re - found = [] - for resource in self._get_all_resources(): - if re.search(pattern, resource.name): - found.append(resource) - return found - - # 动态添加方法到resource_tracker - self.resource_tracker.find_by_type = find_by_type - self.resource_tracker.find_by_category = find_by_category - self.resource_tracker.find_by_name_pattern = find_by_name_pattern - - def _find_resources_by_type_recursive(self, resource, target_type): - """递归查找指定类型的资源""" - found = [] - if isinstance(resource, target_type): - found.append(resource) - - # 递归查找子资源 - children = getattr(resource, "children", []) - for child in children: - found.extend(self._find_resources_by_type_recursive(child, target_type)) - - return found - - def _get_all_resources(self) -> List[PLRResource]: - """获取所有资源""" - all_resources = [] - - def collect_resources(resource): - all_resources.append(resource) - children = getattr(resource, "children", []) - for child in children: - collect_resources(child) - - collect_resources(self.plr_deck) - return all_resources - - def _initialize_materials(self): - """初始化物料""" - try: - # 确定创建顺序,确保父资源先于子资源创建 - creation_order = self._determine_creation_order() - - # 按顺序创建资源 - for resource_id in creation_order: - config = self.children_config[resource_id] - self._create_plr_resource(resource_id, config) - - logger.info(f"物料管理系统初始化完成,共创建 {len(self.plr_resources)} 个资源") - - except Exception as e: - logger.error(f"物料初始化失败: {e}") - - def _determine_creation_order(self) -> List[str]: - """确定资源创建顺序""" - order = [] - visited = set() - - def visit(resource_id: str): - if resource_id in visited: - return - visited.add(resource_id) - - config = self.children_config.get(resource_id, {}) - parent_id = config.get("parent") - - # 如果有父资源,先访问父资源 - if parent_id and parent_id in self.children_config: - visit(parent_id) - - order.append(resource_id) - - for resource_id in self.children_config: - visit(resource_id) - - return order - - def _create_plr_resource(self, resource_id: str, config: Dict[str, Any]): - """创建PyLabRobot资源""" - try: - resource_type = config.get("type", "unknown") - data = config.get("data", {}) - location_config = config.get("location", {}) - - # 创建位置坐标 - location = PLRCoordinate( - x=location_config.get("x", 0.0), - y=location_config.get("y", 0.0), - z=location_config.get("z", 0.0) - ) - - # 根据类型创建资源 - resource = self._create_resource_by_type(resource_id, resource_type, config, data, location) - - if resource: - # 设置父子关系 - parent_id = config.get("parent") - if parent_id and parent_id in self.plr_resources: - parent_resource = self.plr_resources[parent_id] - parent_resource.assign_child_resource(resource, location) - else: - # 直接放在deck上 - self.plr_deck.assign_child_resource(resource, location) - - # 保存资源引用 - self.plr_resources[resource_id] = resource - - # 注册到resource tracker - self.resource_tracker.add_resource(resource) - - logger.debug(f"创建资源成功: {resource_id} ({resource_type})") - - except Exception as e: - logger.error(f"创建资源失败 {resource_id}: {e}") - - @abstractmethod - def _create_resource_by_type( - self, - resource_id: str, - resource_type: str, - config: Dict[str, Any], - data: Dict[str, Any], - location: PLRCoordinate - ) -> Optional[PLRResource]: - """根据类型创建资源 - 子类必须实现""" - pass - - # ============ 物料查找接口 ============ - - def find_materials_by_type(self, material_type: str) -> List[PLRResource]: - """按材料类型查找物料""" - return self.resource_tracker.find_by_category(material_type) - - def find_material_by_id(self, resource_id: str) -> Optional[PLRResource]: - """按ID查找物料""" - return self.plr_resources.get(resource_id) - - def find_available_positions(self, position_type: str) -> List[PLRResource]: - """查找可用位置""" - positions = self.resource_tracker.find_by_category(position_type) - available = [] - - for pos in positions: - if hasattr(pos, 'is_available') and pos.is_available(): - available.append(pos) - elif hasattr(pos, 'children') and len(pos.children) == 0: - available.append(pos) - - return available - - def get_material_inventory(self) -> Dict[str, int]: - """获取物料库存统计""" - inventory = {} - - for resource in self._get_all_resources(): - if hasattr(resource, 'category'): - category = resource.category - inventory[category] = inventory.get(category, 0) + 1 - - return inventory - - # ============ 物料状态更新接口 ============ - - def update_material_location(self, material_id: str, new_location: PLRCoordinate) -> bool: - """更新物料位置""" - try: - material = self.find_material_by_id(material_id) - if material: - material.location = new_location - return True - return False - except Exception as e: - logger.error(f"更新物料位置失败: {e}") - return False - - def move_material(self, material_id: str, target_container_id: str) -> bool: - """移动物料到目标容器""" - try: - material = self.find_material_by_id(material_id) - target = self.find_material_by_id(target_container_id) - - if material and target: - # 从原位置移除 - if material.parent: - material.parent.unassign_child_resource(material) - - # 添加到新位置 - target.assign_child_resource(material) - return True - - return False - - except Exception as e: - logger.error(f"移动物料失败: {e}") - return False - - # ============ 资源转换接口 ============ - - def convert_to_unilab_format(self, plr_resource: PLRResource) -> Dict[str, Any]: - """将PyLabRobot资源转换为UniLab格式""" - return resource_plr_to_ulab(plr_resource) - - def convert_from_unilab_format(self, unilab_resource: Dict[str, Any]) -> PLRResource: - """将UniLab格式转换为PyLabRobot资源""" - return resource_ulab_to_plr(unilab_resource) - - def get_deck_state(self) -> Dict[str, Any]: - """获取Deck状态""" - try: - return { - "deck_info": { - "name": self.plr_deck.name, - "size": { - "x": self.plr_deck.size_x, - "y": self.plr_deck.size_y, - "z": self.plr_deck.size_z - }, - "children_count": len(self.plr_deck.children) - }, - "resources": { - resource_id: self.convert_to_unilab_format(resource) - for resource_id, resource in self.plr_resources.items() - }, - "inventory": self.get_material_inventory() - } - except Exception as e: - logger.error(f"获取Deck状态失败: {e}") - return {"error": str(e)} - - # ============ 数据持久化接口 ============ - - def save_state_to_file(self, file_path: str) -> bool: - """保存状态到文件""" - try: - state = self.get_deck_state() - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(state, f, indent=2, ensure_ascii=False) - logger.info(f"状态已保存到: {file_path}") - return True - except Exception as e: - logger.error(f"保存状态失败: {e}") - return False - - def load_state_from_file(self, file_path: str) -> bool: - """从文件加载状态""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - state = json.load(f) - - # 重新创建资源 - self._recreate_resources_from_state(state) - logger.info(f"状态已从文件加载: {file_path}") - return True - - except Exception as e: - logger.error(f"加载状态失败: {e}") - return False - - def _recreate_resources_from_state(self, state: Dict[str, Any]): - """从状态重新创建资源""" - # 清除现有资源 - self.plr_resources.clear() - self.plr_deck.children.clear() - - # 从状态重新创建 - resources_data = state.get("resources", {}) - for resource_id, resource_data in resources_data.items(): - try: - plr_resource = self.convert_from_unilab_format(resource_data) - self.plr_resources[resource_id] = plr_resource - self.plr_deck.assign_child_resource(plr_resource) - except Exception as e: - logger.error(f"重新创建资源失败 {resource_id}: {e}") - - -class CoinCellMaterialManagement(MaterialManagementBase): - """纽扣电池物料管理类 - - 从 button_battery_station 抽取的物料管理功能 - """ - - def _create_resource_by_type( - self, - resource_id: str, - resource_type: str, - config: Dict[str, Any], - data: Dict[str, Any], - location: PLRCoordinate - ) -> Optional[PLRResource]: - """根据类型创建纽扣电池相关资源""" - - # 导入纽扣电池资源类 - from unilabos.device_comms.button_battery_station import ( - MaterialPlate, PlateSlot, ClipMagazine, BatteryPressSlot, - TipBox64, WasteTipBox, BottleRack, Battery, ElectrodeSheet - ) - - try: - if resource_type == "material_plate": - return self._create_material_plate(resource_id, config, data, location) - - elif resource_type == "plate_slot": - return self._create_plate_slot(resource_id, config, data, location) - - elif resource_type == "clip_magazine": - return self._create_clip_magazine(resource_id, config, data, location) - - elif resource_type == "battery_press_slot": - return self._create_battery_press_slot(resource_id, config, data, location) - - elif resource_type == "tip_box": - return self._create_tip_box(resource_id, config, data, location) - - elif resource_type == "waste_tip_box": - return self._create_waste_tip_box(resource_id, config, data, location) - - elif resource_type == "bottle_rack": - return self._create_bottle_rack(resource_id, config, data, location) - - elif resource_type == "battery": - return self._create_battery(resource_id, config, data, location) - - else: - logger.warning(f"未知的资源类型: {resource_type}") - return None - - except Exception as e: - logger.error(f"创建资源失败 {resource_id} ({resource_type}): {e}") - return None - - def _create_material_plate(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建料板""" - from unilabos.device_comms.button_battery_station import MaterialPlate, ElectrodeSheet - - plate = MaterialPlate( - name=resource_id, - size_x=config.get("size_x", 80.0), - size_y=config.get("size_y", 80.0), - size_z=config.get("size_z", 10.0), - hole_diameter=config.get("hole_diameter", 15.0), - hole_depth=config.get("hole_depth", 8.0), - hole_spacing_x=config.get("hole_spacing_x", 20.0), - hole_spacing_y=config.get("hole_spacing_y", 20.0), - number=data.get("number", "") - ) - plate.location = location - - # 如果有预填充的极片数据,创建极片 - electrode_sheets = data.get("electrode_sheets", []) - for i, sheet_data in enumerate(electrode_sheets): - if i < len(plate.children): # 确保不超过洞位数量 - hole = plate.children[i] - sheet = ElectrodeSheet( - name=f"{resource_id}_sheet_{i}", - diameter=sheet_data.get("diameter", 14.0), - thickness=sheet_data.get("thickness", 0.1), - mass=sheet_data.get("mass", 0.01), - material_type=sheet_data.get("material_type", "cathode"), - info=sheet_data.get("info", "") - ) - hole.place_electrode_sheet(sheet) - - return plate - - def _create_plate_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建板槽位""" - from unilabos.device_comms.button_battery_station import PlateSlot - - slot = PlateSlot( - name=resource_id, - max_plates=config.get("max_plates", 8) - ) - slot.location = location - return slot - - def _create_clip_magazine(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建子弹夹""" - from unilabos.device_comms.button_battery_station import ClipMagazine - - magazine = ClipMagazine( - name=resource_id, - size_x=config.get("size_x", 150.0), - size_y=config.get("size_y", 100.0), - size_z=config.get("size_z", 50.0), - hole_diameter=config.get("hole_diameter", 15.0), - hole_depth=config.get("hole_depth", 40.0), - hole_spacing=config.get("hole_spacing", 25.0), - max_sheets_per_hole=config.get("max_sheets_per_hole", 100) - ) - magazine.location = location - return magazine - - def _create_battery_press_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建电池压制槽""" - from unilabos.device_comms.button_battery_station import BatteryPressSlot - - slot = BatteryPressSlot( - name=resource_id, - diameter=config.get("diameter", 20.0), - depth=config.get("depth", 15.0) - ) - slot.location = location - return slot - - def _create_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建枪头盒""" - from unilabos.device_comms.button_battery_station import TipBox64 - - tip_box = TipBox64( - name=resource_id, - size_x=config.get("size_x", 127.8), - size_y=config.get("size_y", 85.5), - size_z=config.get("size_z", 60.0), - with_tips=data.get("with_tips", True) - ) - tip_box.location = location - return tip_box - - def _create_waste_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建废枪头盒""" - from unilabos.device_comms.button_battery_station import WasteTipBox - - waste_box = WasteTipBox( - name=resource_id, - size_x=config.get("size_x", 127.8), - size_y=config.get("size_y", 85.5), - size_z=config.get("size_z", 60.0), - max_tips=config.get("max_tips", 100) - ) - waste_box.location = location - return waste_box - - def _create_bottle_rack(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建瓶架""" - from unilabos.device_comms.button_battery_station import BottleRack - - rack = BottleRack( - name=resource_id, - size_x=config.get("size_x", 210.0), - size_y=config.get("size_y", 140.0), - size_z=config.get("size_z", 100.0), - bottle_diameter=config.get("bottle_diameter", 30.0), - bottle_height=config.get("bottle_height", 100.0), - position_spacing=config.get("position_spacing", 35.0) - ) - rack.location = location - return rack - - def _create_battery(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): - """创建电池""" - from unilabos.device_comms.button_battery_station import Battery - - battery = Battery( - name=resource_id, - diameter=config.get("diameter", 20.0), - height=config.get("height", 3.2), - max_volume=config.get("max_volume", 100.0), - barcode=data.get("barcode", "") - ) - battery.location = location - return battery - - # ============ 纽扣电池特定查找方法 ============ - - def find_material_plates(self): - """查找所有料板""" - from unilabos.device_comms.button_battery_station import MaterialPlate - return self.resource_tracker.find_by_type(MaterialPlate) - - def find_batteries(self): - """查找所有电池""" - from unilabos.device_comms.button_battery_station import Battery - return self.resource_tracker.find_by_type(Battery) - - def find_electrode_sheets(self): - """查找所有极片""" - found = [] - plates = self.find_material_plates() - for plate in plates: - for hole in plate.children: - if hasattr(hole, 'has_electrode_sheet') and hole.has_electrode_sheet(): - found.append(hole._electrode_sheet) - return found - - def find_plate_slots(self): - """查找所有板槽位""" - from unilabos.device_comms.button_battery_station import PlateSlot - return self.resource_tracker.find_by_type(PlateSlot) - - def find_clip_magazines(self): - """查找所有子弹夹""" - from unilabos.device_comms.button_battery_station import ClipMagazine - return self.resource_tracker.find_by_type(ClipMagazine) - - def find_press_slots(self): - """查找所有压制槽""" - from unilabos.device_comms.button_battery_station import BatteryPressSlot - return self.resource_tracker.find_by_type(BatteryPressSlot) diff --git a/unilabos/registry/resources/bioyond/deck.yaml b/unilabos/registry/resources/bioyond/deck.yaml index d28218b4..07f78ea4 100644 --- a/unilabos/registry/resources/bioyond/deck.yaml +++ b/unilabos/registry/resources/bioyond/deck.yaml @@ -46,6 +46,3 @@ CoincellDeck: init_param_schema: {} registry_type: resource version: 1.0.0 - - - diff --git a/unilabos/resources/battery/bottle_carriers.py b/unilabos/resources/battery/bottle_carriers.py new file mode 100644 index 00000000..9d9827cd --- /dev/null +++ b/unilabos/resources/battery/bottle_carriers.py @@ -0,0 +1,56 @@ +from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d + +from unilabos.resources.itemized_carrier import Bottle, BottleCarrier +from unilabos.resources.bioyond.YB_bottles import ( + YB_pei_ye_xiao_Bottle, +) +# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial + + +def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier: + """12瓶载架 - 2x6布局""" + # 载架尺寸 (mm) + carrier_size_x = 120.0 + carrier_size_y = 250.0 + carrier_size_z = 50.0 + + # 瓶位尺寸 + bottle_diameter = 35.0 + bottle_spacing_x = 35.0 # X方向间距 + bottle_spacing_y = 35.0 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (6 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=2, + num_items_y=6, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="Electrolyte_12VialCarrier", + ) + carrier.num_items_x = 2 + carrier.num_items_y = 6 + carrier.num_items_z = 1 + for i in range(12): + carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}") + return carrier diff --git a/unilabos/resources/battery/magazine.py b/unilabos/resources/battery/magazine.py new file mode 100644 index 00000000..a5a15ccc --- /dev/null +++ b/unilabos/resources/battery/magazine.py @@ -0,0 +1,284 @@ +from typing import Dict, List, Optional, OrderedDict, Union +import math + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources import Resource, ResourceStack, ItemizedResource +from pylabrobot.resources.carrier import create_homogeneous_resources + + +class Magazine(ResourceStack): + """子弹夹洞位类""" + + def __init__( + self, + name: str, + direction: str = 'z', + resources: Optional[List[Resource]] = None, + max_sheets: int = 100, + **kwargs + ): + """初始化子弹夹洞位 + + Args: + name: 洞位名称 + direction: 堆叠方向 + resources: 资源列表 + max_sheets: 最大极片数量 + """ + super().__init__( + name=name, + direction=direction, + resources=resources, + ) + self.max_sheets = max_sheets + + +class MagazineHolder(ItemizedResource): + """子弹夹类 - 有多个洞位,每个洞位放多个极片""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + ordered_items: Optional[Dict[str, Magazine]] = None, + ordering: Optional[OrderedDict[str, str]] = None, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + max_sheets_per_hole: int = 100, + cross_section_type: str = "circle", + category: str = "magazine_holder", + model: Optional[str] = None, + ): + """初始化子弹夹 + + Args: + name: 子弹夹名称 + size_x: 长度 (mm) + size_y: 宽度 (mm) + size_z: 高度 (mm) + hole_diameter: 洞直径 (mm) + hole_depth: 洞深度 (mm) + max_sheets_per_hole: 每个洞位最大极片数量 + category: 类别 + model: 型号 + """ + + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + ) + + # 保存洞位的直径和深度 + self.hole_diameter = hole_diameter + self.hole_depth = hole_depth + self.max_sheets_per_hole = max_sheets_per_hole + self.cross_section_type = cross_section_type + + def serialize(self) -> dict: + return { + **super().serialize(), + "hole_diameter": self.hole_diameter, + "hole_depth": self.hole_depth, + "max_sheets_per_hole": self.max_sheets_per_hole, + "cross_section_type": self.cross_section_type, + } + + +def magazine_factory( + name: str, + size_x: float, + size_y: float, + size_z: float, + locations: List[Coordinate], + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + max_sheets_per_hole: int = 100, + category: str = "magazine_holder", + model: Optional[str] = None, +) -> 'MagazineHolder': + """工厂函数:创建子弹夹 + + Args: + name: 子弹夹名称 + size_x: 长度 (mm) + size_y: 宽度 (mm) + size_z: 高度 (mm) + locations: 洞位坐标列表 + hole_diameter: 洞直径 (mm) + hole_depth: 洞深度 (mm) + max_sheets_per_hole: 每个洞位最大极片数量 + category: 类别 + model: 型号 + """ + # 创建洞位 + _sites = create_homogeneous_resources( + klass=Magazine, + locations=locations, + resource_size_x=hole_diameter, + resource_size_y=hole_diameter, + name_prefix=name, + max_sheets=max_sheets_per_hole, + ) + + # 生成编号键 + keys = [f"A{i+1}" for i in range(len(locations))] + sites = dict(zip(keys, _sites.values())) + + return MagazineHolder( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=sites, + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category=category, + model=model, + ) + + +def MagazineHolder_4( + name: str, + size_x: float = 80.0, + size_y: float = 80.0, + size_z: float = 10.0, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + hole_spacing: float = 25.0, + max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建4孔子弹夹 - 正方形四角排布""" + # 计算4个洞位的坐标(正方形四角排布) + center_x = size_x / 2 + center_y = size_y / 2 + offset = hole_spacing / 2 + + locations = [ + Coordinate(center_x - offset, center_y - offset, size_z - hole_depth), # 左下 + Coordinate(center_x + offset, center_y - offset, size_z - hole_depth), # 右下 + Coordinate(center_x - offset, center_y + offset, size_z - hole_depth), # 左上 + Coordinate(center_x + offset, center_y + offset, size_z - hole_depth), # 右上 + ] + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="clip_magazine_four", + ) + + +def MagazineHolder_2( + name: str, + size_x: float = 80.0, + size_y: float = 80.0, + size_z: float = 10.0, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + hole_spacing: float = 25.0, + max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建2孔子弹夹 - 竖向排布""" + # 计算2个洞位的坐标(竖向排布) + center_x = size_x / 2 + center_y = size_y / 2 + offset = hole_spacing / 2 + + locations = [ + Coordinate(center_x, center_y - offset, size_z - hole_depth), # 下方 + Coordinate(center_x, center_y + offset, size_z - hole_depth), # 上方 + ] + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="clip_magazine_two", + ) + + +def MagazineHolder_1( + name: str, + size_x: float = 80.0, + size_y: float = 80.0, + size_z: float = 10.0, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建1孔子弹夹 - 中心单孔""" + # 计算1个洞位的坐标(中心位置) + center_x = size_x / 2 + center_y = size_y / 2 + + locations = [ + Coordinate(center_x, center_y, size_z - hole_depth), # 中心 + ] + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="clip_magazine_one", + ) + + +def MagazineHolder_6( + name: str, + size_x: float = 80.0, + size_y: float = 80.0, + size_z: float = 40.0, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + hole_spacing: float = 20.0, + max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建6孔子弹夹 - 六边形排布""" + # 计算6个洞位的坐标(六边形排布:中心1个,周围5个) + center_x = size_x / 2 + center_y = size_y / 2 + + locations = [] + + # 周围6个孔,按六边形排布 + for i in range(6): + angle = i * 60 * math.pi / 180 # 每60度一个孔 + x = center_x + hole_spacing * math.cos(angle) + y = center_y + hole_spacing * math.sin(angle) + locations.append(Coordinate(x, y, size_z - hole_depth)) + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="clip_magazine_six", + ) \ No newline at end of file diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index fef09e25..831a0734 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -29,7 +29,7 @@ class Bottle(Well): size_x: float = 0.0, size_y: float = 0.0, size_z: float = 0.0, - barcode: Optional[str] = "", + barcode: Optional[str] = None, category: str = "container", model: Optional[str] = None, **kwargs, From a2a8ee9088278ba571fa53ede75c6416068e0273 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 21:43:25 +0800 Subject: [PATCH 04/15] fix run async execution error --- unilabos/ros/nodes/base_device_node.py | 39 ++++++++------------------ unilabos/utils/async_util.py | 22 --------------- 2 files changed, 12 insertions(+), 49 deletions(-) delete mode 100644 unilabos/utils/async_util.py diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index f1063123..edf41fbd 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -53,7 +53,7 @@ from unilabos.ros.nodes.resource_tracker import ( ) from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator -from unilabos.utils.async_util import run_async_func +from rclpy.task import Task from unilabos.utils.import_manager import default_manager from unilabos.utils.log import info, debug, warning, error, critical, logger, trace from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str @@ -1385,18 +1385,19 @@ class ROS2DeviceNode: 它不继承设备类,而是通过代理模式访问设备类的属性和方法。 """ - # 类变量,用于循环管理 - _loop = None - _loop_running = False - _loop_thread = None - @classmethod - def get_loop(cls): - return cls._loop + def run_async_func(cls, func, trace_error=True, **kwargs) -> Task: + def _handle_future_exception(fut): + try: + fut.result() + except Exception as e: + error(f"异步任务 {func.__name__} 报错了") + error(traceback.format_exc()) - @classmethod - def run_async_func(cls, func, trace_error=True, **kwargs): - return run_async_func(func, loop=cls._loop, trace_error=trace_error, **kwargs) + future = rclpy.get_global_executor().create_task(func(**kwargs)) + if trace_error: + future.add_done_callback(_handle_future_exception) + return future @property def driver_instance(self): @@ -1436,11 +1437,6 @@ class ROS2DeviceNode: print_publish: 是否打印发布信息 driver_is_ros: """ - # 在初始化时检查循环状态 - if ROS2DeviceNode._loop_running and ROS2DeviceNode._loop_thread is not None: - pass - elif ROS2DeviceNode._loop_thread is None: - self._start_loop() # 保存设备类是否支持异步上下文 self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__") @@ -1529,17 +1525,6 @@ class ROS2DeviceNode: except Exception as e: self._ros_node.lab_logger().error(f"设备后初始化失败: {e}") - def _start_loop(self): - def run_event_loop(): - loop = asyncio.new_event_loop() - ROS2DeviceNode._loop = loop - asyncio.set_event_loop(loop) - loop.run_forever() - - ROS2DeviceNode._loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNodeLoop") - ROS2DeviceNode._loop_thread.start() - logger.info(f"循环线程已启动") - class DeviceInfoType(TypedDict): id: str diff --git a/unilabos/utils/async_util.py b/unilabos/utils/async_util.py deleted file mode 100644 index 0f50a730..00000000 --- a/unilabos/utils/async_util.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -import traceback -from asyncio import get_event_loop - -from unilabos.utils.log import error - - -def run_async_func(func, *, loop=None, trace_error=True, **kwargs): - if loop is None: - loop = get_event_loop() - - def _handle_future_exception(fut): - try: - fut.result() - except Exception as e: - error(f"异步任务 {func.__name__} 报错了") - error(traceback.format_exc()) - - future = asyncio.run_coroutine_threadsafe(func(**kwargs), loop) - if trace_error: - future.add_done_callback(_handle_future_exception) - return future From 4c3972820bca6c572394fd7d63a4e1b5d8641ac1 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:42:12 +0800 Subject: [PATCH 05/15] support sleep and create_task in node --- .../devices/liquid_handling/prcxi/prcxi.py | 395 +++++++++++------- unilabos/ros/nodes/base_device_node.py | 19 +- unilabos/ros/nodes/presets/host_node.py | 3 +- 3 files changed, 257 insertions(+), 160 deletions(-) diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index d9c04331..a8677f49 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -30,6 +30,7 @@ from pylabrobot.liquid_handling.standard import ( from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class PRCXIError(RuntimeError): @@ -162,6 +163,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract): ) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) + def post_init(self, ros_node: BaseROS2DeviceNode): + super().post_init(ros_node) + self._unilabos_backend.post_init(ros_node) + def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]): return super().set_liquid(wells, liquid_names, volumes) @@ -424,6 +429,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): _num_channels = 8 # 默认通道数为 8 _is_reset_ok = False + _ros_node: BaseROS2DeviceNode @property def is_reset_ok(self) -> bool: @@ -456,6 +462,9 @@ class PRCXI9300Backend(LiquidHandlerBackend): self._execute_setup = setup self.debug = debug + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def create_protocol(self, protocol_name): self.protocol_name = protocol_name self.steps_todo_list = [] @@ -500,7 +509,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): self.api_client.call("IAutomation", "Reset") while not self.is_reset_ok: print("Waiting for PRCXI9300 to reset...") - await asyncio.sleep(1) + await self._ros_node.sleep(1) print("PRCXI9300 reset successfully.") except ConnectionRefusedError as e: raise RuntimeError( @@ -533,7 +542,9 @@ class PRCXI9300Backend(LiquidHandlerBackend): tipspot_index = tipspot.parent.children.index(tipspot) tip_columns.append(tipspot_index // 8) if len(set(tip_columns)) != 1: - raise ValueError("All pickups must be from the same tip column. Found different columns: " + str(tip_columns)) + raise ValueError( + "All pickups must be from the same tip column. Found different columns: " + str(tip_columns) + ) PlateNo = plate_indexes[0] + 1 hole_col = tip_columns[0] + 1 hole_row = 1 @@ -1109,12 +1120,15 @@ class PRCXI9300Api: "LiquidDispensingMethod": liquid_method, } + class DefaultLayout: def __init__(self, product_name: str = "PRCXI9300"): self.labresource = {} if product_name not in ["PRCXI9300", "PRCXI9320"]: - raise ValueError(f"Unsupported product_name: {product_name}. Only 'PRCXI9300' and 'PRCXI9320' are supported.") + raise ValueError( + f"Unsupported product_name: {product_name}. Only 'PRCXI9300' and 'PRCXI9320' are supported." + ) if product_name == "PRCXI9300": self.rows = 2 @@ -1129,25 +1143,93 @@ class DefaultLayout: self.layout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] self.trash_slot = 16 self.waste_liquid_slot = 12 - self.default_layout = {"MatrixId":f"{time.time()}","MatrixName":f"{time.time()}","MatrixCount":16,"WorkTablets": - [{"Number": 1, "Code": "T1", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 2, "Code": "T2", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 3, "Code": "T3", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 4, "Code": "T4", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 5, "Code": "T5", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 6, "Code": "T6", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 7, "Code": "T7", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 8, "Code": "T8", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 9, "Code": "T9", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 10, "Code": "T10", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 11, "Code": "T11", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 12, "Code": "T12", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}}, # 这个设置成废液槽,用储液槽表示 - {"Number": 13, "Code": "T13", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 14, "Code": "T14", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 15, "Code": "T15", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}}, - {"Number": 16, "Code": "T16", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}} # 这个设置成垃圾桶,用储液槽表示 -] -} + self.default_layout = { + "MatrixId": f"{time.time()}", + "MatrixName": f"{time.time()}", + "MatrixCount": 16, + "WorkTablets": [ + { + "Number": 1, + "Code": "T1", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 2, + "Code": "T2", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 3, + "Code": "T3", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 4, + "Code": "T4", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 5, + "Code": "T5", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 6, + "Code": "T6", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 7, + "Code": "T7", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 8, + "Code": "T8", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 9, + "Code": "T9", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 10, + "Code": "T10", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 11, + "Code": "T11", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 12, + "Code": "T12", + "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}, + }, # 这个设置成废液槽,用储液槽表示 + { + "Number": 13, + "Code": "T13", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 14, + "Code": "T14", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 15, + "Code": "T15", + "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}, + }, + { + "Number": 16, + "Code": "T16", + "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}, + }, # 这个设置成垃圾桶,用储液槽表示 + ], + } def get_layout(self) -> Dict[str, Any]: return { @@ -1155,7 +1237,7 @@ class DefaultLayout: "columns": self.columns, "layout": self.layout, "trash_slot": self.trash_slot, - "waste_liquid_slot": self.waste_liquid_slot + "waste_liquid_slot": self.waste_liquid_slot, } def get_trash_slot(self) -> int: @@ -1178,17 +1260,19 @@ class DefaultLayout: reserved_positions = {12, 16} available_positions = [i for i in range(1, 17) if i not in reserved_positions] - # 计算总需求 + # 计算总需求 total_needed = sum(count for _, _, count in needs) if total_needed > len(available_positions): - raise ValueError(f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置(排除位置12和16)") + raise ValueError( + f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置(排除位置12和16)" + ) # 依次分配位置 current_pos = 0 for reagent_name, material_name, count in needs: - material_uuid = self.labresource[material_name]['uuid'] - material_enum = self.labresource[material_name]['materialEnum'] + material_uuid = self.labresource[material_name]["uuid"] + material_enum = self.labresource[material_name]["materialEnum"] for _ in range(count): if current_pos >= len(available_positions): @@ -1196,17 +1280,18 @@ class DefaultLayout: position = available_positions[current_pos] # 找到对应的tablet并更新 - for tablet in self.default_layout['WorkTablets']: - if tablet['Number'] == position: - tablet['Material']['uuid'] = material_uuid - tablet['Material']['materialEnum'] = material_enum - layout_list.append(dict(reagent_name=reagent_name, material_name=material_name, positions=position)) + for tablet in self.default_layout["WorkTablets"]: + if tablet["Number"] == position: + tablet["Material"]["uuid"] = material_uuid + tablet["Material"]["materialEnum"] = material_enum + layout_list.append( + dict(reagent_name=reagent_name, material_name=material_name, positions=position) + ) break current_pos += 1 return self.default_layout, layout_list - if __name__ == "__main__": # Example usage # 1. 用导出的json,给每个T1 T2板子设定相应的物料,如果是孔板和枪头盒,要对应区分 @@ -1302,10 +1387,7 @@ if __name__ == "__main__": # # # plate2.set_well_liquids(plate_2_liquids) - - - - # handler = PRCXI9300Handler(deck=deck, host="10.181.214.132", port=9999, + # handler = PRCXI9300Handler(deck=deck, host="10.181.214.132", port=9999, # timeout=10.0, setup=False, debug=False, # simulator=True, # matrix_id="71593", @@ -1391,10 +1473,7 @@ if __name__ == "__main__": # # input("Press Enter to continue...") # Wait for user input before proceeding # # print("PRCXI9300Handler initialized with deck and host settings.") - - -### 9320 ### - + ### 9320 ### deck = PRCXI9300Deck(name="PRCXI_Deck", size_x=100, size_y=100, size_z=100) @@ -1412,12 +1491,15 @@ if __name__ == "__main__": new_plate: PRCXI9300Container = PRCXI9300Container.deserialize(well_containers) return new_plate - def get_tip_rack(name: str, child_prefix: str="tip") -> PRCXI9300Container: + def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300Container: tip_racks = opentrons_96_tiprack_10ul(name).serialize() tip_rack = PRCXI9300Container( - name=name, size_x=50, size_y=50, size_z=10, category="tip_rack", ordering=collections.OrderedDict({ - k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items() - }) + name=name, + size_x=50, + size_y=50, + size_z=10, + category="tip_rack", + ordering=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}), ) tip_rack_serialized = tip_rack.serialize() tip_rack_serialized["parent_name"] = deck.name @@ -1629,6 +1711,7 @@ if __name__ == "__main__": ) backend: PRCXI9300Backend = handler.backend from pylabrobot.resources import set_volume_tracking + set_volume_tracking(enabled=True) # res = backend.api_client.get_all_materials() asyncio.run(handler.setup()) # Initialize the handler and setup the connection @@ -1640,10 +1723,10 @@ if __name__ == "__main__": for well in plate13.get_all_items(): # well_pos = well.name.split("_")[1] # 走一行 - # if well_pos.startswith("A"): - if well.name.startswith("PlateT13"): # 走整个Plate + # if well_pos.startswith("A"): + if well.name.startswith("PlateT13"): # 走整个Plate asyncio.run(handler.dispense([well], [0.01], [0])) - + # asyncio.run(handler.dispense([plate10.get_item("H12")], [1], [0])) # asyncio.run(handler.dispense([plate13.get_item("A1")], [1], [0])) # asyncio.run(handler.dispense([plate14.get_item("C5")], [1], [0])) @@ -1652,26 +1735,25 @@ if __name__ == "__main__": asyncio.run(handler.run_protocol()) time.sleep(5) os._exit(0) -# 第一种情景:一个孔往多个孔加液 + # 第一种情景:一个孔往多个孔加液 # plate_2_liquids = handler.set_group("water", [plate2.children[0]], [300]) # plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100]*23) -# 第二个情景:多个孔往多个孔加液(但是个数得对应) - plate_2_liquids = handler.set_group("water", plate2.children[:23], [300]*23) - plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100]*23) + # 第二个情景:多个孔往多个孔加液(但是个数得对应) + plate_2_liquids = handler.set_group("water", plate2.children[:23], [300] * 23) + plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100] * 23) # plate11.set_well_liquids([("Water", 100) if (i % 8 == 0 and i // 8 < 6) else (None, 100) for i in range(96)]) # Set liquids for every 8 wells in plate8 # plate11.set_well_liquids([("Water", 100) if (i % 8 == 0 and i // 8 < 6) else (None, 100) for i in range(96)]) # Set liquids for every 8 wells in plate8 -# A = tree_to_list([resource_plr_to_ulab(deck)]) -# # with open("deck.json", "w", encoding="utf-8") as f: -# # json.dump(A, f, indent=4, ensure_ascii=False) + # A = tree_to_list([resource_plr_to_ulab(deck)]) + # # with open("deck.json", "w", encoding="utf-8") as f: + # # json.dump(A, f, indent=4, ensure_ascii=False) -# print(plate11.get_well(0).tracker.get_used_volume()) - # Initialize the backend and setup the connection + # print(plate11.get_well(0).tracker.get_used_volume()) + # Initialize the backend and setup the connection asyncio.run(handler.transfer_group("water", "master_mix", 10)) # Reset tip tracking - # asyncio.run(handler.pick_up_tips([plate8.children[8]],[0])) # print(plate8.children[8]) # asyncio.run(handler.run_protocol()) @@ -1685,121 +1767,118 @@ if __name__ == "__main__": # print(plate1.children[0]) # asyncio.run(handler.discard_tips([0])) -# asyncio.run(handler.add_liquid( -# asp_vols=[10]*7, -# dis_vols=[10]*7, -# reagent_sources=plate11.children[:7], -# targets=plate1.children[2:9], -# use_channels=[0], -# flow_rates=[None] * 7, -# offsets=[Coordinate(0, 0, 0)] * 7, -# liquid_height=[None] * 7, -# blow_out_air_volume=[None] * 2, -# delays=None, -# mix_time=3, -# mix_vol=5, -# spread="custom", -# )) + # asyncio.run(handler.add_liquid( + # asp_vols=[10]*7, + # dis_vols=[10]*7, + # reagent_sources=plate11.children[:7], + # targets=plate1.children[2:9], + # use_channels=[0], + # flow_rates=[None] * 7, + # offsets=[Coordinate(0, 0, 0)] * 7, + # liquid_height=[None] * 7, + # blow_out_air_volume=[None] * 2, + # delays=None, + # mix_time=3, + # mix_vol=5, + # spread="custom", + # )) # asyncio.run(handler.run_protocol()) # Run the protocol + # # # asyncio.run(handler.transfer_liquid( + # # # asp_vols=[10]*2, + # # # dis_vols=[10]*2, + # # # sources=plate11.children[:2], + # # # targets=plate11.children[-2:], + # # # use_channels=[0], + # # # offsets=[Coordinate(0, 0, 0)] * 4, + # # # liquid_height=[None] * 2, + # # # blow_out_air_volume=[None] * 2, + # # # delays=None, + # # # mix_times=3, + # # # mix_vol=5, + # # # spread="wide", + # # # tip_racks=[plate8] + # # # )) + # # # asyncio.run(handler.remove_liquid( + # # # vols=[10]*2, + # # # sources=plate11.children[:2], + # # # waste_liquid=plate11.children[43], + # # # use_channels=[0], + # # # offsets=[Coordinate(0, 0, 0)] * 4, + # # # liquid_height=[None] * 2, + # # # blow_out_air_volume=[None] * 2, + # # # delays=None, + # # # spread="wide" + # # # )) + # # asyncio.run(handler.run_protocol()) + # # # asyncio.run(handler.discard_tips()) + # # # asyncio.run(handler.mix(well_containers.children[:8 + # # # ], mix_time=3, mix_vol=50, height_to_bottom=0.5, offsets=Coordinate(0, 0, 0), mix_rate=100)) + # # #print(json.dumps(handler._unilabos_backend.steps_todo_list, indent=2)) # Print matrix info -# # # asyncio.run(handler.transfer_liquid( -# # # asp_vols=[10]*2, -# # # dis_vols=[10]*2, -# # # sources=plate11.children[:2], -# # # targets=plate11.children[-2:], -# # # use_channels=[0], -# # # offsets=[Coordinate(0, 0, 0)] * 4, -# # # liquid_height=[None] * 2, -# # # blow_out_air_volume=[None] * 2, -# # # delays=None, -# # # mix_times=3, -# # # mix_vol=5, -# # # spread="wide", -# # # tip_racks=[plate8] -# # # )) - -# # # asyncio.run(handler.remove_liquid( -# # # vols=[10]*2, -# # # sources=plate11.children[:2], -# # # waste_liquid=plate11.children[43], -# # # use_channels=[0], -# # # offsets=[Coordinate(0, 0, 0)] * 4, -# # # liquid_height=[None] * 2, -# # # blow_out_air_volume=[None] * 2, -# # # delays=None, -# # # spread="wide" -# # # )) -# # asyncio.run(handler.run_protocol()) - -# # # asyncio.run(handler.discard_tips()) -# # # asyncio.run(handler.mix(well_containers.children[:8 -# # # ], mix_time=3, mix_vol=50, height_to_bottom=0.5, offsets=Coordinate(0, 0, 0), mix_rate=100)) -# # #print(json.dumps(handler._unilabos_backend.steps_todo_list, indent=2)) # Print matrix info - - -# # # asyncio.run(handler.remove_liquid( -# # # vols=[100]*16, -# # # sources=well_containers.children[-16:], -# # # waste_liquid=well_containers.children[:16], # 这个有些奇怪,但是好像也只能这么写 -# # # use_channels=[0, 1, 2, 3, 4, 5, 6, 7], -# # # flow_rates=[None] * 32, -# # # offsets=[Coordinate(0, 0, 0)] * 32, -# # # liquid_height=[None] * 32, -# # # blow_out_air_volume=[None] * 32, -# # # spread="wide", -# # # )) -# # # asyncio.run(handler.transfer_liquid( -# # # asp_vols=[100]*16, -# # # dis_vols=[100]*16, -# # # tip_racks=[tip_rack], -# # # sources=well_containers.children[-16:], -# # # targets=well_containers.children[:16], -# # # use_channels=[0, 1, 2, 3, 4, 5, 6, 7], -# # # offsets=[Coordinate(0, 0, 0)] * 32, -# # # asp_flow_rates=[None] * 16, -# # # dis_flow_rates=[None] * 16, -# # # liquid_height=[None] * 32, -# # # blow_out_air_volume=[None] * 32, -# # # mix_times=3, -# # # mix_vol=50, -# # # spread="wide", -# # # )) -# # print(json.dumps(handler._unilabos_backend.steps_todo_list, indent=2)) # Print matrix info -# # # input("pick_up_tips add step") - #asyncio.run(handler.run_protocol()) # Run the protocol -# # # input("Running protocol...") -# # # input("Press Enter to continue...") # Wait for user input before proceeding -# # # print("PRCXI9300Handler initialized with deck and host settings.") - - -# 一些推荐版位组合的测试样例: - -# 一些推荐版位组合的测试样例: + # # # asyncio.run(handler.remove_liquid( + # # # vols=[100]*16, + # # # sources=well_containers.children[-16:], + # # # waste_liquid=well_containers.children[:16], # 这个有些奇怪,但是好像也只能这么写 + # # # use_channels=[0, 1, 2, 3, 4, 5, 6, 7], + # # # flow_rates=[None] * 32, + # # # offsets=[Coordinate(0, 0, 0)] * 32, + # # # liquid_height=[None] * 32, + # # # blow_out_air_volume=[None] * 32, + # # # spread="wide", + # # # )) + # # # asyncio.run(handler.transfer_liquid( + # # # asp_vols=[100]*16, + # # # dis_vols=[100]*16, + # # # tip_racks=[tip_rack], + # # # sources=well_containers.children[-16:], + # # # targets=well_containers.children[:16], + # # # use_channels=[0, 1, 2, 3, 4, 5, 6, 7], + # # # offsets=[Coordinate(0, 0, 0)] * 32, + # # # asp_flow_rates=[None] * 16, + # # # dis_flow_rates=[None] * 16, + # # # liquid_height=[None] * 32, + # # # blow_out_air_volume=[None] * 32, + # # # mix_times=3, + # # # mix_vol=50, + # # # spread="wide", + # # # )) + # # print(json.dumps(handler._unilabos_backend.steps_todo_list, indent=2)) # Print matrix info + # # # input("pick_up_tips add step") + # asyncio.run(handler.run_protocol()) # Run the protocol + # # # input("Running protocol...") + # # # input("Press Enter to continue...") # Wait for user input before proceeding + # # # print("PRCXI9300Handler initialized with deck and host settings.") + # 一些推荐版位组合的测试样例: + # 一些推荐版位组合的测试样例: with open("prcxi_material.json", "r") as f: material_info = json.load(f) layout = DefaultLayout("PRCXI9320") layout.add_lab_resource(material_info) - MatrixLayout_1, dict_1 = layout.recommend_layout([ - ("reagent_1", "96 细胞培养皿", 3), - ("reagent_2", "12道储液槽", 1), - ("reagent_3", "200μL Tip头", 7), - ("reagent_4", "10μL加长 Tip头", 1), - ]) + MatrixLayout_1, dict_1 = layout.recommend_layout( + [ + ("reagent_1", "96 细胞培养皿", 3), + ("reagent_2", "12道储液槽", 1), + ("reagent_3", "200μL Tip头", 7), + ("reagent_4", "10μL加长 Tip头", 1), + ] + ) print(dict_1) - MatrixLayout_2, dict_2 = layout.recommend_layout([ - ("reagent_1", "96深孔板", 4), - ("reagent_2", "12道储液槽", 1), - ("reagent_3", "200μL Tip头", 1), - ("reagent_4", "10μL加长 Tip头", 1), - ]) + MatrixLayout_2, dict_2 = layout.recommend_layout( + [ + ("reagent_1", "96深孔板", 4), + ("reagent_2", "12道储液槽", 1), + ("reagent_3", "200μL Tip头", 1), + ("reagent_4", "10μL加长 Tip头", 1), + ] + ) # with open("prcxi_material.json", "r") as f: # material_info = json.load(f) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index edf41fbd..2fc7ea7d 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -53,7 +53,7 @@ from unilabos.ros.nodes.resource_tracker import ( ) from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator -from rclpy.task import Task +from rclpy.task import Task, Future from unilabos.utils.import_manager import default_manager from unilabos.utils.log import info, debug, warning, error, critical, logger, trace from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str @@ -555,6 +555,15 @@ class BaseROS2DeviceNode(Node, Generic[T]): rclpy.get_global_executor().add_node(self) self.lab_logger().debug(f"ROS节点初始化完成") + async def sleep(self, rel_time: float, callback_group=None): + if callback_group is None: + callback_group = self.callback_group + await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group) + + @classmethod + async def create_task(cls, func, trace_error=True, **kwargs) -> Task: + return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs) + async def update_resource(self, resources: List["ResourcePLR"]): r = SerialCommand.Request() tree_set = ResourceTreeSet.from_plr_resources(resources) @@ -1399,6 +1408,14 @@ class ROS2DeviceNode: future.add_done_callback(_handle_future_exception) return future + @classmethod + async def async_wait_for(cls, node: Node, wait_time: float, callback_group=None): + future = Future() + timer = node.create_timer(wait_time, lambda : future.set_result(None), callback_group=callback_group, clock=node.get_clock()) + await future + timer.cancel() + node.destroy_timer(timer) + @property def driver_instance(self): return self._driver_instance diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 43d16e8d..346cf9c2 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -18,7 +18,8 @@ from unilabos_msgs.srv import ( ResourceDelete, ResourceUpdate, ResourceList, - SerialCommand, ResourceGet, + SerialCommand, + ResourceGet, ) # type: ignore from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unique_identifier_msgs.msg import UUID From 0f341e9b4dbee0f6e18ef2332578685f0c4d1026 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:49:11 +0800 Subject: [PATCH 06/15] modify devices to use correct executor (sleep, create_task) --- test/registry/example_devices.py | 33 +- unilabos/devices/cnc/grbl_async.py | 11 +- unilabos/devices/cnc/mock.py | 9 +- .../laiyu_liquid/core/laiyu_liquid_main.py | 423 +++++++++--------- .../liquid_handler_abstract.py | 10 +- .../devices/pump_and_valve/runze_async.py | 11 +- .../devices/virtual/virtual_centrifuge.py | 9 +- unilabos/devices/virtual/virtual_column.py | 9 +- unilabos/devices/virtual/virtual_filter.py | 202 ++++----- unilabos/devices/virtual/virtual_heatchill.py | 9 +- unilabos/devices/virtual/virtual_rotavap.py | 273 ++++++----- unilabos/devices/virtual/virtual_separator.py | 11 +- .../devices/virtual/virtual_solenoid_valve.py | 10 +- .../virtual/virtual_solid_dispenser.py | 9 +- unilabos/devices/virtual/virtual_stirrer.py | 11 +- .../devices/virtual/virtual_transferpump.py | 13 +- 16 files changed, 597 insertions(+), 456 deletions(-) diff --git a/test/registry/example_devices.py b/test/registry/example_devices.py index d5b26b2c..d41c7b43 100644 --- a/test/registry/example_devices.py +++ b/test/registry/example_devices.py @@ -3,7 +3,8 @@ """ import asyncio -from typing import Dict, Any, Optional, List +from typing import Dict, Any, List +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class SmartPumpController: @@ -14,6 +15,8 @@ class SmartPumpController: 适用于实验室自动化系统中的液体处理任务。 """ + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"): """ 初始化智能泵控制器 @@ -30,6 +33,9 @@ class SmartPumpController: self.calibration_factor = 1.0 self.pump_mode = "continuous" # continuous, volume, rate + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def connect_device(self, timeout: int = 10) -> bool: """ 连接到泵设备 @@ -90,7 +96,7 @@ class SmartPumpController: pump_time = (volume / flow_rate) * 60 # 转换为秒 self.current_flow_rate = flow_rate - await asyncio.sleep(min(pump_time, 3.0)) # 模拟泵送过程 + await self._ros_node.sleep(min(pump_time, 3.0)) # 模拟泵送过程 self.total_volume_pumped += volume self.current_flow_rate = 0.0 @@ -170,6 +176,8 @@ class AdvancedTemperatureController: 适用于需要精确温度控制的化学反应和材料处理过程。 """ + _ros_node: BaseROS2DeviceNode + def __init__(self, controller_id: str = "temp_controller_01"): """ 初始化温度控制器 @@ -185,6 +193,9 @@ class AdvancedTemperatureController: self.pid_enabled = True self.temperature_history: List[Dict] = [] + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def set_target_temperature(self, temperature: float, rate: float = 10.0) -> bool: """ 设置目标温度 @@ -238,7 +249,7 @@ class AdvancedTemperatureController: } ) - await asyncio.sleep(step_time) + await self._ros_node.sleep(step_time) # 保持历史记录不超过100条 if len(self.temperature_history) > 100: @@ -330,6 +341,8 @@ class MultiChannelAnalyzer: 常用于光谱分析、电化学测量等应用场景。 """ + _ros_node: BaseROS2DeviceNode + def __init__(self, analyzer_id: str = "analyzer_01", channels: int = 8): """ 初始化多通道分析仪 @@ -344,6 +357,9 @@ class MultiChannelAnalyzer: self.is_measuring = False self.sample_rate = 1000 # Hz + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def configure_channel(self, channel: int, enabled: bool = True, unit: str = "V") -> bool: """ 配置通道 @@ -376,7 +392,7 @@ class MultiChannelAnalyzer: # 模拟数据采集 measurements = [] - for second in range(duration): + for _ in range(duration): timestamp = asyncio.get_event_loop().time() frame_data = {} @@ -391,7 +407,7 @@ class MultiChannelAnalyzer: measurements.append({"timestamp": timestamp, "data": frame_data}) - await asyncio.sleep(1.0) # 每秒采集一次 + await self._ros_node.sleep(1.0) # 每秒采集一次 self.is_measuring = False @@ -465,6 +481,8 @@ class AutomatedDispenser: 集成称重功能,确保分配精度和重现性。 """ + _ros_node: BaseROS2DeviceNode + def __init__(self, dispenser_id: str = "dispenser_01"): """ 初始化自动分配器 @@ -479,6 +497,9 @@ class AutomatedDispenser: self.container_capacity = 1000.0 # mL self.precision_mode = True + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def move_to_position(self, x: float, y: float, z: float) -> bool: """ 移动到指定位置 @@ -517,7 +538,7 @@ class AutomatedDispenser: if viscosity == "high": dispense_time *= 2 # 高粘度液体需要更长时间 - await asyncio.sleep(min(dispense_time, 5.0)) # 最多等待5秒 + await self._ros_node.sleep(min(dispense_time, 5.0)) # 最多等待5秒 self.dispensed_total += volume diff --git a/unilabos/devices/cnc/grbl_async.py b/unilabos/devices/cnc/grbl_async.py index 7e5ac7f3..3ecd4ba8 100644 --- a/unilabos/devices/cnc/grbl_async.py +++ b/unilabos/devices/cnc/grbl_async.py @@ -12,6 +12,7 @@ from serial import Serial from serial.serialutil import SerialException from unilabos.messages import Point3D +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class GrblCNCConnectionError(Exception): @@ -32,6 +33,7 @@ class GrblCNCInfo: class GrblCNCAsync: _status: str = "Offline" _position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) + _ros_node: BaseROS2DeviceNode def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, 0, 60)): self.port = port @@ -58,6 +60,9 @@ class GrblCNCAsync: self._run_future: Optional[Future[Any]] = None self._run_lock = Lock() + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def _read_all(self): data = self._serial.read_until(b"\n") data_decoded = data.decode() @@ -148,7 +153,7 @@ class GrblCNCAsync: try: await self._query(command) while True: - await asyncio.sleep(0.2) # Wait for 0.5 seconds before polling again + await self._ros_node.sleep(0.2) # Wait for 0.5 seconds before polling again status = await self.get_status() if "Idle" in status: @@ -214,7 +219,7 @@ class GrblCNCAsync: self._pose_number = i self.pose_number_remaining = len(points) - i await self.set_position(point) - await asyncio.sleep(0.5) + await self._ros_node.sleep(0.5) self._step_number = -1 async def stop_operation(self): @@ -235,7 +240,7 @@ class GrblCNCAsync: async def open(self): if self._read_task: raise GrblCNCConnectionError - self._read_task = asyncio.create_task(self._read_loop()) + self._read_task = self._ros_node.create_task(self._read_loop()) try: await self.get_status() diff --git a/unilabos/devices/cnc/mock.py b/unilabos/devices/cnc/mock.py index b8c52f16..ebe96833 100644 --- a/unilabos/devices/cnc/mock.py +++ b/unilabos/devices/cnc/mock.py @@ -2,6 +2,8 @@ import time import asyncio from pydantic import BaseModel +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class Point3D(BaseModel): x: float @@ -14,9 +16,14 @@ def d(a: Point3D, b: Point3D) -> float: class MockCNCAsync: + _ros_node: BaseROS2DeviceNode["MockCNCAsync"] + def __init__(self): self._position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) self._status = "Idle" + + def post_create(self, ros_node): + self._ros_node = ros_node @property def position(self) -> Point3D: @@ -38,5 +45,5 @@ class MockCNCAsync: self._position.x = current_pos.x + (position.x - current_pos.x) / 20 * (i+1) self._position.y = current_pos.y + (position.y - current_pos.y) / 20 * (i+1) self._position.z = current_pos.z + (position.z - current_pos.z) / 20 * (i+1) - await asyncio.sleep(move_time / 20) + await self._ros_node.sleep(move_time / 20) self._status = "Idle" diff --git a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py index 96092556..f369a208 100644 --- a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py +++ b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py @@ -15,108 +15,113 @@ from typing import List, Optional, Dict, Any, Union, Tuple from dataclasses import dataclass from abc import ABC, abstractmethod +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + # 基础导入 try: from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well + PYLABROBOT_AVAILABLE = True except ImportError: # 如果 pylabrobot 不可用,创建基础的模拟类 PYLABROBOT_AVAILABLE = False - + class Resource: def __init__(self, name: str): self.name = name - + class Deck(Resource): pass - + class Plate(Resource): pass - + class TipRack(Resource): pass - + class Tip(Resource): pass - + class Well(Resource): pass + # LaiYu_Liquid 控制器导入 try: - from .controllers.pipette_controller import ( - PipetteController, TipStatus, LiquidClass, LiquidParameters - ) - from .controllers.xyz_controller import ( - XYZController, MachineConfig, CoordinateOrigin, MotorAxis - ) + from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters + from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis + CONTROLLERS_AVAILABLE = True except ImportError: CONTROLLERS_AVAILABLE = False + # 创建模拟的控制器类 class PipetteController: def __init__(self, *args, **kwargs): pass - + def connect(self): return True - + def initialize(self): return True - + class XYZController: def __init__(self, *args, **kwargs): pass - + def connect_device(self): return True + logger = logging.getLogger(__name__) class LaiYuLiquidError(RuntimeError): """LaiYu_Liquid 设备异常""" + pass @dataclass class LaiYuLiquidConfig: """LaiYu_Liquid 设备配置""" + port: str = "/dev/cu.usbserial-3130" # RS485转USB端口 address: int = 1 # 设备地址 baudrate: int = 9600 # 波特率 timeout: float = 5.0 # 通信超时时间 - + # 工作台尺寸 deck_width: float = 340.0 # 工作台宽度 (mm) deck_height: float = 250.0 # 工作台高度 (mm) deck_depth: float = 160.0 # 工作台深度 (mm) - + # 移液参数 max_volume: float = 1000.0 # 最大体积 (μL) min_volume: float = 0.1 # 最小体积 (μL) - + # 运动参数 max_speed: float = 100.0 # 最大速度 (mm/s) acceleration: float = 50.0 # 加速度 (mm/s²) - + # 安全参数 safe_height: float = 50.0 # 安全高度 (mm) tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm) liquid_detection: bool = True # 液面检测 - + # 取枪头相关参数 tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm) tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s) tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm) tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm) tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm) - + # 丢弃枪头相关参数 tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm) tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm) trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm) - + # 安全范围配置 deck_width: float = 300.0 # 工作台宽度 (mm) deck_height: float = 200.0 # 工作台高度 (mm) @@ -128,25 +133,25 @@ class LaiYuLiquidConfig: class LaiYuLiquidDeck: """LaiYu_Liquid 工作台管理""" - + def __init__(self, config: LaiYuLiquidConfig): self.config = config self.resources: Dict[str, Resource] = {} self.positions: Dict[str, Tuple[float, float, float]] = {} - + def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]): """添加资源到工作台""" self.resources[name] = resource self.positions[name] = position - + def get_resource(self, name: str) -> Optional[Resource]: """获取资源""" return self.resources.get(name) - + def get_position(self, name: str) -> Optional[Tuple[float, float, float]]: """获取资源位置""" return self.positions.get(name) - + def list_resources(self) -> List[str]: """列出所有资源""" return list(self.resources.keys()) @@ -154,8 +159,18 @@ class LaiYuLiquidDeck: class LaiYuLiquidContainer: """LaiYu_Liquid 容器类""" - - def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, container_type: str = "", volume: float = 0.0, max_volume: float = 1000.0, lid_height: float = 0.0): + + def __init__( + self, + name: str, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + container_type: str = "", + volume: float = 0.0, + max_volume: float = 1000.0, + lid_height: float = 0.0, + ): self.name = name self.size_x = size_x self.size_y = size_y @@ -166,19 +181,19 @@ class LaiYuLiquidContainer: self.max_volume = max_volume self.last_updated = time.time() self.child_resources = {} # 存储子资源 - + @property def is_empty(self) -> bool: return self.volume <= 0.0 - + @property def is_full(self) -> bool: return self.volume >= self.max_volume - + @property def available_volume(self) -> float: return max(0.0, self.max_volume - self.volume) - + def add_volume(self, volume: float) -> bool: """添加体积""" if self.volume + volume <= self.max_volume: @@ -186,7 +201,7 @@ class LaiYuLiquidContainer: self.last_updated = time.time() return True return False - + def remove_volume(self, volume: float) -> bool: """移除体积""" if self.volume >= volume: @@ -194,20 +209,25 @@ class LaiYuLiquidContainer: self.last_updated = time.time() return True return False - + def assign_child_resource(self, resource, location=None): """分配子资源 - 与 PyLabRobot 资源管理系统兼容""" - if hasattr(resource, 'name'): - self.child_resources[resource.name] = { - 'resource': resource, - 'location': location - } + if hasattr(resource, "name"): + self.child_resources[resource.name] = {"resource": resource, "location": location} class LaiYuLiquidTipRack: """LaiYu_Liquid 吸头架类""" - - def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, tip_count: int = 96, tip_volume: float = 1000.0): + + def __init__( + self, + name: str, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + tip_count: int = 96, + tip_volume: float = 1000.0, + ): self.name = name self.size_x = size_x self.size_y = size_y @@ -216,34 +236,31 @@ class LaiYuLiquidTipRack: self.tip_volume = tip_volume self.tips_available = [True] * tip_count self.child_resources = {} # 存储子资源 - + @property def available_tips(self) -> int: return sum(self.tips_available) - + @property def is_empty(self) -> bool: return self.available_tips == 0 - + def pick_tip(self, position: int) -> bool: """拾取吸头""" if 0 <= position < self.tip_count and self.tips_available[position]: self.tips_available[position] = False return True return False - + def has_tip(self, position: int) -> bool: """检查位置是否有吸头""" if 0 <= position < self.tip_count: return self.tips_available[position] return False - + def assign_child_resource(self, resource, location=None): """分配子资源到指定位置""" - self.child_resources[resource.name] = { - 'resource': resource, - 'location': location - } + self.child_resources[resource.name] = {"resource": resource, "location": location} def get_module_info(): @@ -253,36 +270,32 @@ def get_module_info(): "version": "1.0.0", "description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能", "author": "UniLabOS Team", - "capabilities": [ - "移液器控制", - "XYZ轴运动控制", - "吸头架管理", - "板和容器管理", - "资源位置管理" - ], - "dependencies": { - "required": ["serial"], - "optional": ["pylabrobot"] - } + "capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"], + "dependencies": {"required": ["serial"], "optional": ["pylabrobot"]}, } class LaiYuLiquidBackend: """LaiYu_Liquid 硬件通信后端""" - - def __init__(self, config: LaiYuLiquidConfig, deck: Optional['LaiYuLiquidDeck'] = None): + + _ros_node: BaseROS2DeviceNode + + def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None): self.config = config self.deck = deck # 工作台引用,用于获取资源位置信息 self.pipette_controller = None self.xyz_controller = None self.is_connected = False self.is_initialized = False - + # 状态跟踪 self.current_position = (0.0, 0.0, 0.0) self.tip_attached = False self.current_volume = 0.0 - + + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def _validate_position(self, x: float, y: float, z: float) -> bool: """验证位置是否在安全范围内""" try: @@ -290,71 +303,71 @@ class LaiYuLiquidBackend: if not (0 <= x <= self.config.deck_width): logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]") return False - + # 检查Y轴范围 if not (0 <= y <= self.config.deck_height): logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]") return False - + # 检查Z轴范围(负值表示向下,0为工作台表面) if not (-self.config.deck_depth <= z <= self.config.safe_height): logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]") return False - + return True except Exception as e: logger.error(f"位置验证失败: {e}") return False - + def _check_hardware_ready(self) -> bool: """检查硬件是否准备就绪""" if not self.is_connected: logger.error("设备未连接") return False - + if CONTROLLERS_AVAILABLE: if self.xyz_controller is None: logger.error("XYZ控制器未初始化") return False - + return True - + async def emergency_stop(self) -> bool: """紧急停止所有运动""" try: logger.warning("执行紧急停止") - + if CONTROLLERS_AVAILABLE and self.xyz_controller: # 停止XYZ控制器 await self.xyz_controller.stop_all_motion() logger.info("XYZ控制器已停止") - + if self.pipette_controller: # 停止移液器控制器 await self.pipette_controller.stop() logger.info("移液器控制器已停止") - + return True except Exception as e: logger.error(f"紧急停止失败: {e}") return False - + async def move_to_safe_position(self) -> bool: """移动到安全位置""" try: if not self._check_hardware_ready(): return False - + safe_position = ( self.config.deck_width / 2, # 工作台中心X self.config.deck_height / 2, # 工作台中心Y - self.config.safe_height # 安全高度Z + self.config.safe_height, # 安全高度Z ) - + if not self._validate_position(*safe_position): logger.error("安全位置无效") return False - + if CONTROLLERS_AVAILABLE and self.xyz_controller: await self.xyz_controller.move_to_work_coord(*safe_position) self.current_position = safe_position @@ -365,33 +378,28 @@ class LaiYuLiquidBackend: self.current_position = safe_position logger.info("模拟移动到安全位置") return True - + except Exception as e: logger.error(f"移动到安全位置失败: {e}") return False - + async def setup(self) -> bool: """设置硬件连接""" try: if CONTROLLERS_AVAILABLE: # 初始化移液器控制器 - self.pipette_controller = PipetteController( - port=self.config.port, - address=self.config.address - ) - + self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address) + # 初始化XYZ控制器 machine_config = MachineConfig() self.xyz_controller = XYZController( - port=self.config.port, - baudrate=self.config.baudrate, - machine_config=machine_config + port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config ) - + # 连接设备 pipette_connected = await asyncio.to_thread(self.pipette_controller.connect) xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device) - + if pipette_connected and xyz_connected: self.is_connected = True logger.info("LaiYu_Liquid 硬件连接成功") @@ -404,124 +412,123 @@ class LaiYuLiquidBackend: logger.info("LaiYu_Liquid 运行在模拟模式") self.is_connected = True return True - + except Exception as e: logger.error(f"LaiYu_Liquid 设置失败: {e}") return False - + async def stop(self): """停止设备""" try: - if self.pipette_controller and hasattr(self.pipette_controller, 'disconnect'): + if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"): await asyncio.to_thread(self.pipette_controller.disconnect) - - if self.xyz_controller and hasattr(self.xyz_controller, 'disconnect'): + + if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"): await asyncio.to_thread(self.xyz_controller.disconnect) - + self.is_connected = False self.is_initialized = False logger.info("LaiYu_Liquid 已停止") - + except Exception as e: logger.error(f"LaiYu_Liquid 停止失败: {e}") - + async def move_to(self, x: float, y: float, z: float) -> bool: """移动到指定位置""" try: if not self.is_connected: raise LaiYuLiquidError("设备未连接") - + # 模拟移动 - await asyncio.sleep(0.1) # 模拟移动时间 + await self._ros_node.sleep(0.1) # 模拟移动时间 self.current_position = (x, y, z) logger.debug(f"移动到位置: ({x}, {y}, {z})") return True - + except Exception as e: logger.error(f"移动失败: {e}") return False - + async def pick_up_tip(self, tip_rack: str, position: int) -> bool: """拾取吸头 - 包含真正的Z轴下降控制""" try: # 硬件准备检查 if not self._check_hardware_ready(): return False - + if self.tip_attached: logger.warning("已有吸头附着,无法拾取新吸头") return False - + logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头") - + # 获取枪头架位置信息 if self.deck is None: logger.error("工作台未初始化") return False - + tip_position = self.deck.get_position(tip_rack) if tip_position is None: logger.error(f"未找到枪头架 {tip_rack} 的位置信息") return False - + # 计算具体枪头位置(这里简化处理,实际应根据position计算偏移) tip_x, tip_y, tip_z = tip_position - + # 验证所有关键位置的安全性 safe_z = tip_z + self.config.tip_approach_height pickup_z = tip_z - self.config.tip_pickup_force_depth retract_z = tip_z + self.config.tip_pickup_retract_height - - if not (self._validate_position(tip_x, tip_y, safe_z) and - self._validate_position(tip_x, tip_y, pickup_z) and - self._validate_position(tip_x, tip_y, retract_z)): + + if not ( + self._validate_position(tip_x, tip_y, safe_z) + and self._validate_position(tip_x, tip_y, pickup_z) + and self._validate_position(tip_x, tip_y, retract_z) + ): logger.error("枪头拾取位置超出安全范围") return False - + if CONTROLLERS_AVAILABLE and self.xyz_controller: # 真实硬件控制流程 logger.info("使用真实XYZ控制器进行枪头拾取") - + try: # 1. 移动到枪头上方的安全位置 safe_z = tip_z + self.config.tip_approach_height logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})") move_success = await asyncio.to_thread( - self.xyz_controller.move_to_work_coord, - tip_x, tip_y, safe_z + self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z ) if not move_success: logger.error("移动到枪头上方失败") return False - + # 2. Z轴下降到枪头位置 pickup_z = tip_z - self.config.tip_pickup_force_depth logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm") z_down_success = await asyncio.to_thread( - self.xyz_controller.move_to_work_coord, - tip_x, tip_y, pickup_z + self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z ) if not z_down_success: logger.error("Z轴下降到枪头位置失败") return False - + # 3. 等待一小段时间确保枪头牢固附着 - await asyncio.sleep(0.2) - + await self._ros_node.sleep(0.2) + # 4. Z轴上升到回退高度 retract_z = tip_z + self.config.tip_pickup_retract_height logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm") z_up_success = await asyncio.to_thread( - self.xyz_controller.move_to_work_coord, - tip_x, tip_y, retract_z + self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z ) if not z_up_success: logger.error("Z轴上升失败") return False - + # 5. 更新当前位置 self.current_position = (tip_x, tip_y, retract_z) - + except Exception as move_error: logger.error(f"枪头拾取过程中发生错误: {move_error}") # 尝试移动到安全位置 @@ -529,35 +536,35 @@ class LaiYuLiquidBackend: await self.emergency_stop() await self.move_to_safe_position() return False - + else: # 模拟模式 logger.info("模拟模式:执行枪头拾取动作") - await asyncio.sleep(1.0) # 模拟整个拾取过程的时间 + await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间 self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height) - + # 6. 标记枪头已附着 self.tip_attached = True logger.info("吸头拾取成功") return True - + except Exception as e: logger.error(f"拾取吸头失败: {e}") return False - + async def drop_tip(self, location: str = "trash") -> bool: """丢弃吸头 - 包含真正的Z轴控制""" try: # 硬件准备检查 if not self._check_hardware_ready(): return False - + if not self.tip_attached: logger.warning("没有吸头附着,无需丢弃") return True - + logger.info(f"开始丢弃吸头到 {location}") - + # 确定丢弃位置 if location == "trash": # 使用配置中的垃圾桶位置 @@ -567,48 +574,48 @@ class LaiYuLiquidBackend: if self.deck is None: logger.error("工作台未初始化") return False - + drop_position = self.deck.get_position(location) if drop_position is None: logger.error(f"未找到丢弃位置 {location} 的信息") return False drop_x, drop_y, drop_z = drop_position - + # 验证丢弃位置的安全性 safe_z = drop_z + self.config.safe_height drop_height_z = drop_z + self.config.tip_drop_height - - if not (self._validate_position(drop_x, drop_y, safe_z) and - self._validate_position(drop_x, drop_y, drop_height_z)): + + if not ( + self._validate_position(drop_x, drop_y, safe_z) + and self._validate_position(drop_x, drop_y, drop_height_z) + ): logger.error("枪头丢弃位置超出安全范围") return False - + if CONTROLLERS_AVAILABLE and self.xyz_controller: # 真实硬件控制流程 logger.info("使用真实XYZ控制器进行枪头丢弃") - + try: # 1. 移动到丢弃位置上方的安全高度 safe_z = drop_z + self.config.tip_drop_height logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})") move_success = await asyncio.to_thread( - self.xyz_controller.move_to_work_coord, - drop_x, drop_y, safe_z + self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z ) if not move_success: logger.error("移动到丢弃位置上方失败") return False - + # 2. Z轴下降到丢弃高度 logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm") z_down_success = await asyncio.to_thread( - self.xyz_controller.move_to_work_coord, - drop_x, drop_y, drop_z + self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z ) if not z_down_success: logger.error("Z轴下降到丢弃位置失败") return False - + # 3. 执行枪头弹出动作(如果有移液器控制器) if self.pipette_controller: try: @@ -617,23 +624,22 @@ class LaiYuLiquidBackend: logger.info("执行枪头弹出命令") except Exception as e: logger.warning(f"枪头弹出命令失败: {e}") - + # 4. 等待一小段时间确保枪头完全脱离 - await asyncio.sleep(0.3) - + await self._ros_node.sleep(0.3) + # 5. Z轴上升到安全高度 logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm") z_up_success = await asyncio.to_thread( - self.xyz_controller.move_to_work_coord, - drop_x, drop_y, safe_z + self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z ) if not z_up_success: logger.error("Z轴上升失败") return False - + # 6. 更新当前位置 self.current_position = (drop_x, drop_y, safe_z) - + except Exception as drop_error: logger.error(f"枪头丢弃过程中发生错误: {drop_error}") # 尝试移动到安全位置 @@ -641,63 +647,63 @@ class LaiYuLiquidBackend: await self.emergency_stop() await self.move_to_safe_position() return False - + else: # 模拟模式 logger.info("模拟模式:执行枪头丢弃动作") - await asyncio.sleep(0.8) # 模拟整个丢弃过程的时间 + await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间 self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height) - + # 7. 标记枪头已脱离,清空体积 self.tip_attached = False self.current_volume = 0.0 logger.info("吸头丢弃成功") return True - + except Exception as e: logger.error(f"丢弃吸头失败: {e}") return False - + async def aspirate(self, volume: float, location: str) -> bool: """吸取液体""" try: if not self.is_connected: raise LaiYuLiquidError("设备未连接") - + if not self.tip_attached: raise LaiYuLiquidError("没有吸头附着") - + if volume <= 0 or volume > self.config.max_volume: raise LaiYuLiquidError(f"体积超出范围: {volume}") - + # 模拟吸取 - await asyncio.sleep(0.3) + await self._ros_node.sleep(0.3) self.current_volume += volume logger.debug(f"从 {location} 吸取 {volume} μL") return True - + except Exception as e: logger.error(f"吸取失败: {e}") return False - + async def dispense(self, volume: float, location: str) -> bool: """分配液体""" try: if not self.is_connected: raise LaiYuLiquidError("设备未连接") - + if not self.tip_attached: raise LaiYuLiquidError("没有吸头附着") - + if volume <= 0 or volume > self.current_volume: raise LaiYuLiquidError(f"分配体积无效: {volume}") - + # 模拟分配 - await asyncio.sleep(0.3) + await self._ros_node.sleep(0.3) self.current_volume -= volume logger.debug(f"向 {location} 分配 {volume} μL") return True - + except Exception as e: logger.error(f"分配失败: {e}") return False @@ -705,7 +711,7 @@ class LaiYuLiquidBackend: class LaiYuLiquid: """LaiYu_Liquid 主要接口类""" - + def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs): # 如果传入了关键字参数,创建配置对象 if kwargs and config is None: @@ -717,37 +723,37 @@ class LaiYuLiquid: self.config = LaiYuLiquidConfig(**config_params) else: self.config = config or LaiYuLiquidConfig() - + # 先创建deck,然后传递给backend self.deck = LaiYuLiquidDeck(self.config) self.backend = LaiYuLiquidBackend(self.config, self.deck) self.is_setup = False - + @property def current_position(self) -> Tuple[float, float, float]: """获取当前位置""" return self.backend.current_position - + @property def current_volume(self) -> float: """获取当前体积""" return self.backend.current_volume - + @property def is_connected(self) -> bool: """获取连接状态""" return self.backend.is_connected - + @property def is_initialized(self) -> bool: """获取初始化状态""" return self.backend.is_initialized - + @property def tip_attached(self) -> bool: """获取吸头附着状态""" return self.backend.tip_attached - + async def setup(self) -> bool: """设置液体处理器""" try: @@ -759,27 +765,28 @@ class LaiYuLiquid: except Exception as e: logger.error(f"LaiYu_Liquid 设置失败: {e}") return False - + async def stop(self): """停止液体处理器""" await self.backend.stop() self.is_setup = False - - async def transfer(self, source: str, target: str, volume: float, - tip_rack: str = "tip_rack_1", tip_position: int = 0) -> bool: + + async def transfer( + self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0 + ) -> bool: """液体转移""" try: if not self.is_setup: raise LaiYuLiquidError("设备未设置") - + # 获取源和目标位置 source_pos = self.deck.get_position(source) target_pos = self.deck.get_position(target) tip_pos = self.deck.get_position(tip_rack) - + if not all([source_pos, target_pos, tip_pos]): raise LaiYuLiquidError("位置信息不完整") - + # 执行转移步骤 steps = [ ("移动到吸头架", self.backend.move_to(*tip_pos)), @@ -788,22 +795,22 @@ class LaiYuLiquid: ("吸取液体", self.backend.aspirate(volume, source)), ("移动到目标位置", self.backend.move_to(*target_pos)), ("分配液体", self.backend.dispense(volume, target)), - ("丢弃吸头", self.backend.drop_tip()) + ("丢弃吸头", self.backend.drop_tip()), ] - + for step_name, step_coro in steps: logger.debug(f"执行步骤: {step_name}") success = await step_coro if not success: raise LaiYuLiquidError(f"步骤失败: {step_name}") - + logger.info(f"液体转移完成: {source} -> {target}, {volume} μL") return True - + except Exception as e: logger.error(f"液体转移失败: {e}") return False - + def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]): """添加资源到工作台""" if resource_type == "plate": @@ -812,9 +819,9 @@ class LaiYuLiquid: resource = TipRack(name) else: resource = Resource(name) - + self.deck.add_resource(name, resource, position) - + def get_status(self) -> Dict[str, Any]: """获取设备状态""" return { @@ -823,59 +830,59 @@ class LaiYuLiquid: "current_position": self.backend.current_position, "tip_attached": self.backend.tip_attached, "current_volume": self.backend.current_volume, - "resources": self.deck.list_resources() + "resources": self.deck.list_resources(), } def create_quick_setup() -> LaiYuLiquidDeck: """ 创建快速设置的LaiYu液体处理工作站 - + Returns: LaiYuLiquidDeck: 配置好的工作台实例 """ # 创建默认配置 config = LaiYuLiquidConfig() - + # 创建工作台 deck = LaiYuLiquidDeck(config) - + # 导入资源创建函数 try: from .laiyu_liquid_res import ( create_tip_rack_1000ul, create_tip_rack_200ul, create_96_well_plate, - create_waste_container + create_waste_container, ) - + # 添加基本资源 tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000") tip_rack_200 = create_tip_rack_200ul("tip_rack_200") plate_96 = create_96_well_plate("plate_96") waste = create_waste_container("waste") - + # 添加到工作台 deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0)) deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0)) deck.add_resource("plate_96", plate_96, (250, 50, 0)) deck.add_resource("waste", waste, (50, 150, 0)) - + except ImportError: # 如果资源模块不可用,创建空的工作台 logger.warning("资源模块不可用,创建空的工作台") - + return deck __all__ = [ "LaiYuLiquid", - "LaiYuLiquidBackend", + "LaiYuLiquidBackend", "LaiYuLiquidConfig", "LaiYuLiquidDeck", "LaiYuLiquidContainer", "LaiYuLiquidTipRack", "LaiYuLiquidError", "create_quick_setup", - "get_module_info" -] \ No newline at end of file + "get_module_info", +] diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 69b757b5..32e370fe 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -25,6 +25,8 @@ from pylabrobot.resources import ( Tip, ) +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class LiquidHandlerMiddleware(LiquidHandler): def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8): @@ -536,6 +538,7 @@ class LiquidHandlerMiddleware(LiquidHandler): class LiquidHandlerAbstract(LiquidHandlerMiddleware): """Extended LiquidHandler with additional operations.""" support_touch_tip = True + _ros_node: BaseROS2DeviceNode def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8): """Initialize a LiquidHandler. @@ -548,8 +551,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): self.group_info = dict() super().__init__(backend, deck, simulator, channel_num) + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + @classmethod - def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]): + def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]): """Set the liquid in a well.""" for well, liquid_name, volume in zip(wells, liquid_names, volumes): well.set_liquids([(liquid_name, volume)]) # type: ignore @@ -1081,7 +1087,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): print(f"Waiting time: {msg}") print(f"Current time: {time.strftime('%H:%M:%S')}") print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}") - await asyncio.sleep(seconds) + await self._ros_node.sleep(seconds) if msg: print(f"Done: {msg}") print(f"Current time: {time.strftime('%H:%M:%S')}") diff --git a/unilabos/devices/pump_and_valve/runze_async.py b/unilabos/devices/pump_and_valve/runze_async.py index 9b8d649e..7bc11155 100644 --- a/unilabos/devices/pump_and_valve/runze_async.py +++ b/unilabos/devices/pump_and_valve/runze_async.py @@ -8,6 +8,8 @@ import serial.tools.list_ports from serial import Serial from serial.serialutil import SerialException +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class RunzeSyringePumpMode(Enum): Normal = 0 @@ -77,6 +79,8 @@ class RunzeSyringePumpInfo: class RunzeSyringePumpAsync: + _ros_node: BaseROS2DeviceNode + def __init__(self, port: str, address: str = "1", volume: float = 25000, mode: RunzeSyringePumpMode = None): self.port = port self.address = address @@ -102,6 +106,9 @@ class RunzeSyringePumpAsync: self._run_future: Optional[Future[Any]] = None self._run_lock = Lock() + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + def _adjust_total_steps(self): self.total_steps = 6000 if self.mode == RunzeSyringePumpMode.Normal else 48000 self.total_steps_vel = 48000 if self.mode == RunzeSyringePumpMode.AccuratePosVel else 6000 @@ -182,7 +189,7 @@ class RunzeSyringePumpAsync: try: await self._query(command) while True: - await asyncio.sleep(0.5) # Wait for 0.5 seconds before polling again + await self._ros_node.sleep(0.5) # Wait for 0.5 seconds before polling again status = await self.query_device_status() if status == '`': @@ -364,7 +371,7 @@ class RunzeSyringePumpAsync: if self._read_task: raise RunzeSyringePumpConnectionError - self._read_task = asyncio.create_task(self._read_loop()) + self._read_task = self._ros_node.create_task(self._read_loop()) try: await self.query_device_status() diff --git a/unilabos/devices/virtual/virtual_centrifuge.py b/unilabos/devices/virtual/virtual_centrifuge.py index 79f9dce0..afce45a9 100644 --- a/unilabos/devices/virtual/virtual_centrifuge.py +++ b/unilabos/devices/virtual/virtual_centrifuge.py @@ -3,9 +3,13 @@ import logging import time as time_module from typing import Dict, Any, Optional +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualCentrifuge: """Virtual centrifuge device - 简化版,只保留核心功能""" + + _ros_node: BaseROS2DeviceNode def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): # 处理可能的不同调用方式 @@ -32,6 +36,9 @@ class VirtualCentrifuge: for key, value in kwargs.items(): if key not in skip_keys and not hasattr(self, key): setattr(self, key, value) + + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node async def initialize(self) -> bool: """Initialize virtual centrifuge""" @@ -132,7 +139,7 @@ class VirtualCentrifuge: break # 每秒更新一次 - await asyncio.sleep(1.0) + await self._ros_node.sleep(1.0) # 离心完成 self.data.update({ diff --git a/unilabos/devices/virtual/virtual_column.py b/unilabos/devices/virtual/virtual_column.py index 892a320f..539f302a 100644 --- a/unilabos/devices/virtual/virtual_column.py +++ b/unilabos/devices/virtual/virtual_column.py @@ -2,9 +2,13 @@ import asyncio import logging from typing import Dict, Any, Optional +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualColumn: """Virtual column device for RunColumn protocol 🏛️""" + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): # 处理可能的不同调用方式 if device_id is None and 'id' in kwargs: @@ -28,6 +32,9 @@ class VirtualColumn: print(f"🏛️ === 虚拟色谱柱 {self.device_id} 已创建 === ✨") print(f"📏 柱参数: 流速={self._max_flow_rate}mL/min | 长度={self._column_length}cm | 直径={self._column_diameter}cm 🔬") + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """Initialize virtual column 🚀""" self.logger.info(f"🔧 初始化虚拟色谱柱 {self.device_id} ✨") @@ -101,7 +108,7 @@ class VirtualColumn: step_time = separation_time / steps for i in range(steps): - await asyncio.sleep(step_time) + await self._ros_node.sleep(step_time) progress = (i + 1) / steps * 100 volume_processed = (i + 1) * 5.0 # 假设每步处理5mL diff --git a/unilabos/devices/virtual/virtual_filter.py b/unilabos/devices/virtual/virtual_filter.py index ffd8f549..98effc99 100644 --- a/unilabos/devices/virtual/virtual_filter.py +++ b/unilabos/devices/virtual/virtual_filter.py @@ -4,70 +4,76 @@ import time as time_module from typing import Dict, Any, Optional from unilabos.compile.utils.vessel_parser import get_vessel +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class VirtualFilter: """Virtual filter device - 完全按照 Filter.action 规范 🌊""" - + + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): - if device_id is None and 'id' in kwargs: - device_id = kwargs.pop('id') - if config is None and 'config' in kwargs: - config = kwargs.pop('config') - + if device_id is None and "id" in kwargs: + device_id = kwargs.pop("id") + if config is None and "config" in kwargs: + config = kwargs.pop("config") + self.device_id = device_id or "unknown_filter" self.config = config or {} self.logger = logging.getLogger(f"VirtualFilter.{self.device_id}") self.data = {} - + # 从config或kwargs中获取配置参数 - self.port = self.config.get('port') or kwargs.get('port', 'VIRTUAL') - self._max_temp = self.config.get('max_temp') or kwargs.get('max_temp', 100.0) - self._max_stir_speed = self.config.get('max_stir_speed') or kwargs.get('max_stir_speed', 1000.0) - self._max_volume = self.config.get('max_volume') or kwargs.get('max_volume', 500.0) - + self.port = self.config.get("port") or kwargs.get("port", "VIRTUAL") + self._max_temp = self.config.get("max_temp") or kwargs.get("max_temp", 100.0) + self._max_stir_speed = self.config.get("max_stir_speed") or kwargs.get("max_stir_speed", 1000.0) + self._max_volume = self.config.get("max_volume") or kwargs.get("max_volume", 500.0) + # 处理其他kwargs参数 - skip_keys = {'port', 'max_temp', 'max_stir_speed', 'max_volume'} + skip_keys = {"port", "max_temp", "max_stir_speed", "max_volume"} for key, value in kwargs.items(): if key not in skip_keys and not hasattr(self, key): setattr(self, key, value) - + + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """Initialize virtual filter 🚀""" self.logger.info(f"🔧 初始化虚拟过滤器 {self.device_id} ✨") - + # 按照 Filter.action 的 feedback 字段初始化 - self.data.update({ - "status": "Idle", - "progress": 0.0, # Filter.action feedback - "current_temp": 25.0, # Filter.action feedback - "filtered_volume": 0.0, # Filter.action feedback - "message": "Ready for filtration" - }) - + self.data.update( + { + "status": "Idle", + "progress": 0.0, # Filter.action feedback + "current_temp": 25.0, # Filter.action feedback + "filtered_volume": 0.0, # Filter.action feedback + "message": "Ready for filtration", + } + ) + self.logger.info(f"✅ 过滤器 {self.device_id} 初始化完成 🌊") return True - + async def cleanup(self) -> bool: """Cleanup virtual filter 🧹""" self.logger.info(f"🧹 清理虚拟过滤器 {self.device_id} 🔚") - - self.data.update({ - "status": "Offline" - }) - + + self.data.update({"status": "Offline"}) + self.logger.info(f"✅ 过滤器 {self.device_id} 清理完成 💤") return True - + async def filter( - self, + self, vessel: dict, filtrate_vessel: dict = {}, - stir: bool = False, - stir_speed: float = 300.0, - temp: float = 25.0, - continue_heatchill: bool = False, - volume: float = 0.0 + stir: bool = False, + stir_speed: float = 300.0, + temp: float = 25.0, + continue_heatchill: bool = False, + volume: float = 0.0, ) -> bool: """Execute filter action - 完全按照 Filter.action 参数 🌊""" vessel_id, _ = get_vessel(vessel) @@ -79,59 +85,52 @@ class VirtualFilter: temp = 25.0 # 0度自动设置为室温 self.logger.info(f"🌡️ 温度自动调整: {original_temp}°C → {temp}°C (室温) 🏠") elif temp < 4.0: - temp = 4.0 # 小于4度自动设置为4度 + temp = 4.0 # 小于4度自动设置为4度 self.logger.info(f"🌡️ 温度自动调整: {original_temp}°C → {temp}°C (最低温度) ❄️") - + self.logger.info(f"🌊 开始过滤操作: {vessel_id} → {filtrate_vessel_id} 🚰") self.logger.info(f" 🌪️ 搅拌: {stir} ({stir_speed} RPM)") self.logger.info(f" 🌡️ 温度: {temp}°C") self.logger.info(f" 💧 体积: {volume}mL") self.logger.info(f" 🔥 保持加热: {continue_heatchill}") - + # 验证参数 if temp > self._max_temp or temp < 4.0: error_msg = f"🌡️ 温度 {temp}°C 超出范围 (4-{self._max_temp}°C) ⚠️" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"Error: 温度超出范围 ⚠️", - "message": error_msg - }) + self.data.update({"status": f"Error: 温度超出范围 ⚠️", "message": error_msg}) return False - + if stir and stir_speed > self._max_stir_speed: error_msg = f"🌪️ 搅拌速度 {stir_speed} RPM 超出范围 (0-{self._max_stir_speed} RPM) ⚠️" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"Error: 搅拌速度超出范围 ⚠️", - "message": error_msg - }) + self.data.update({"status": f"Error: 搅拌速度超出范围 ⚠️", "message": error_msg}) return False - + if volume > self._max_volume: error_msg = f"💧 过滤体积 {volume} mL 超出范围 (0-{self._max_volume} mL) ⚠️" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"Error", - "message": error_msg - }) + self.data.update({"status": f"Error", "message": error_msg}) return False - + # 开始过滤 filter_volume = volume if volume > 0 else 50.0 self.logger.info(f"🚀 开始过滤 {filter_volume}mL 液体 💧") - - self.data.update({ - "status": f"Running", - "current_temp": temp, - "filtered_volume": 0.0, - "progress": 0.0, - "message": f"🚀 Starting filtration: {vessel_id} → {filtrate_vessel_id}" - }) - + + self.data.update( + { + "status": f"Running", + "current_temp": temp, + "filtered_volume": 0.0, + "progress": 0.0, + "message": f"🚀 Starting filtration: {vessel_id} → {filtrate_vessel_id}", + } + ) + try: # 过滤过程 - 实时更新进度 start_time = time_module.time() - + # 根据体积和搅拌估算过滤时间 base_time = filter_volume / 5.0 # 5mL/s 基础速度 if stir: @@ -140,78 +139,79 @@ class VirtualFilter: if temp > 50.0: base_time *= 0.7 # 高温加速过滤 self.logger.info(f"🔥 高温加速过滤,预计时间减少30% ⚡") - + filter_time = max(base_time, 10.0) # 最少10秒 self.logger.info(f"⏱️ 预计过滤时间: {filter_time:.1f}秒 ⌛") - + while True: current_time = time_module.time() elapsed = current_time - start_time remaining = max(0, filter_time - elapsed) progress = min(100.0, (elapsed / filter_time) * 100) current_filtered = (progress / 100.0) * filter_volume - + # 更新状态 - 按照 Filter.action feedback 字段 status_msg = f"🌊 过滤中: {vessel}" if stir: status_msg += f" | 🌪️ 搅拌: {stir_speed} RPM" status_msg += f" | 🌡️ {temp}°C | 📊 {progress:.1f}% | 💧 已过滤: {current_filtered:.1f}mL" - - self.data.update({ - "progress": progress, # Filter.action feedback - "current_temp": temp, # Filter.action feedback - "filtered_volume": current_filtered, # Filter.action feedback - "status": "Running", - "message": f"🌊 Filtering: {progress:.1f}% complete, {current_filtered:.1f}mL filtered" - }) - + + self.data.update( + { + "progress": progress, # Filter.action feedback + "current_temp": temp, # Filter.action feedback + "filtered_volume": current_filtered, # Filter.action feedback + "status": "Running", + "message": f"🌊 Filtering: {progress:.1f}% complete, {current_filtered:.1f}mL filtered", + } + ) + # 进度日志(每25%打印一次) if progress >= 25 and progress % 25 < 1: self.logger.info(f"📊 过滤进度: {progress:.0f}% | 💧 {current_filtered:.1f}mL 完成 ✨") - + if remaining <= 0: break - - await asyncio.sleep(1.0) - + + await self._ros_node.sleep(1.0) + # 过滤完成 final_temp = temp if continue_heatchill else 25.0 final_status = f"✅ 过滤完成: {vessel} | 💧 {filter_volume}mL → {filtrate_vessel}" if continue_heatchill: final_status += " | 🔥 继续加热搅拌" self.logger.info(f"🔥 继续保持加热搅拌状态 🌪️") - - self.data.update({ - "status": final_status, - "progress": 100.0, # Filter.action feedback - "current_temp": final_temp, # Filter.action feedback - "filtered_volume": filter_volume, # Filter.action feedback - "message": f"✅ Filtration completed: {filter_volume}mL filtered from {vessel_id}" - }) - + + self.data.update( + { + "status": final_status, + "progress": 100.0, # Filter.action feedback + "current_temp": final_temp, # Filter.action feedback + "filtered_volume": filter_volume, # Filter.action feedback + "message": f"✅ Filtration completed: {filter_volume}mL filtered from {vessel_id}", + } + ) + self.logger.info(f"🎉 过滤完成! 💧 {filter_volume}mL 从 {vessel_id} 过滤到 {filtrate_vessel_id} ✨") self.logger.info(f"📊 最终状态: 温度 {final_temp}°C | 进度 100% | 体积 {filter_volume}mL 🏁") return True - + except Exception as e: error_msg = f"过滤过程中发生错误: {str(e)} 💥" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"Error", - "message": f"❌ Filtration failed: {str(e)}" - }) + self.data.update({"status": f"Error", "message": f"❌ Filtration failed: {str(e)}"}) return False - + # === 核心状态属性 - 按照 Filter.action feedback 字段 === @property def status(self) -> str: return self.data.get("status", "❓ Unknown") - + @property def progress(self) -> float: """Filter.action feedback 字段 📊""" return self.data.get("progress", 0.0) - + @property def current_temp(self) -> float: """Filter.action feedback 字段 🌡️""" @@ -230,15 +230,15 @@ class VirtualFilter: @property def message(self) -> str: return self.data.get("message", "") - + @property def max_temp(self) -> float: return self._max_temp - + @property def max_stir_speed(self) -> float: return self._max_stir_speed - + @property def max_volume(self) -> float: - return self._max_volume \ No newline at end of file + return self._max_volume diff --git a/unilabos/devices/virtual/virtual_heatchill.py b/unilabos/devices/virtual/virtual_heatchill.py index 2f7e555b..29a9fd28 100644 --- a/unilabos/devices/virtual/virtual_heatchill.py +++ b/unilabos/devices/virtual/virtual_heatchill.py @@ -3,9 +3,13 @@ import logging import time as time_module # 重命名time模块,避免与参数冲突 from typing import Dict, Any +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualHeatChill: """Virtual heat chill device for HeatChillProtocol testing 🌡️""" + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): # 处理可能的不同调用方式 if device_id is None and 'id' in kwargs: @@ -35,6 +39,9 @@ class VirtualHeatChill: print(f"🌡️ === 虚拟温控设备 {self.device_id} 已创建 === ✨") print(f"🔥 温度范围: {self._min_temp}°C ~ {self._max_temp}°C | 🌪️ 最大搅拌: {self._max_stir_speed} RPM") + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """Initialize virtual heat chill 🚀""" self.logger.info(f"🔧 初始化虚拟温控设备 {self.device_id} ✨") @@ -177,7 +184,7 @@ class VirtualHeatChill: break # 等待1秒后再次检查 - await asyncio.sleep(1.0) + await self._ros_node.sleep(1.0) # 操作完成 final_stir_info = f" | 🌪️ 搅拌: {stir_speed} RPM" if stir else "" diff --git a/unilabos/devices/virtual/virtual_rotavap.py b/unilabos/devices/virtual/virtual_rotavap.py index 23e24b7e..5e85d35c 100644 --- a/unilabos/devices/virtual/virtual_rotavap.py +++ b/unilabos/devices/virtual/virtual_rotavap.py @@ -3,13 +3,19 @@ import logging import time as time_module from typing import Dict, Any, Optional +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + + def debug_print(message): """调试输出 🔍""" print(f"🌪️ [ROTAVAP] {message}", flush=True) + class VirtualRotavap: """Virtual rotary evaporator device - 简化版,只保留核心功能 🌪️""" + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): # 处理可能的不同调用方式 if device_id is None and "id" in kwargs: @@ -38,56 +44,65 @@ class VirtualRotavap: print(f"🌪️ === 虚拟旋转蒸发仪 {self.device_id} 已创建 === ✨") print(f"🔥 温度范围: 10°C ~ {self._max_temp}°C | 🌀 转速范围: 10 ~ {self._max_rotation_speed} RPM") + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """Initialize virtual rotary evaporator 🚀""" self.logger.info(f"🔧 初始化虚拟旋转蒸发仪 {self.device_id} ✨") - + # 只保留核心状态 - self.data.update({ - "status": "🏠 待机中", - "rotavap_state": "Ready", # Ready, Evaporating, Completed, Error - "current_temp": 25.0, - "target_temp": 25.0, - "rotation_speed": 0.0, - "vacuum_pressure": 1.0, # 大气压 - "evaporated_volume": 0.0, - "progress": 0.0, - "remaining_time": 0.0, - "message": "🌪️ Ready for evaporation" - }) - + self.data.update( + { + "status": "🏠 待机中", + "rotavap_state": "Ready", # Ready, Evaporating, Completed, Error + "current_temp": 25.0, + "target_temp": 25.0, + "rotation_speed": 0.0, + "vacuum_pressure": 1.0, # 大气压 + "evaporated_volume": 0.0, + "progress": 0.0, + "remaining_time": 0.0, + "message": "🌪️ Ready for evaporation", + } + ) + self.logger.info(f"✅ 旋转蒸发仪 {self.device_id} 初始化完成 🌪️") - self.logger.info(f"📊 设备规格: 温度范围 10°C ~ {self._max_temp}°C | 转速范围 10 ~ {self._max_rotation_speed} RPM") + self.logger.info( + f"📊 设备规格: 温度范围 10°C ~ {self._max_temp}°C | 转速范围 10 ~ {self._max_rotation_speed} RPM" + ) return True async def cleanup(self) -> bool: """Cleanup virtual rotary evaporator 🧹""" self.logger.info(f"🧹 清理虚拟旋转蒸发仪 {self.device_id} 🔚") - - self.data.update({ - "status": "💤 离线", - "rotavap_state": "Offline", - "current_temp": 25.0, - "rotation_speed": 0.0, - "vacuum_pressure": 1.0, - "message": "💤 System offline" - }) - + + self.data.update( + { + "status": "💤 离线", + "rotavap_state": "Offline", + "current_temp": 25.0, + "rotation_speed": 0.0, + "vacuum_pressure": 1.0, + "message": "💤 System offline", + } + ) + self.logger.info(f"✅ 旋转蒸发仪 {self.device_id} 清理完成 💤") return True async def evaporate( - self, - vessel: str, - pressure: float = 0.1, - temp: float = 60.0, + self, + vessel: str, + pressure: float = 0.1, + temp: float = 60.0, time: float = 180.0, stir_speed: float = 100.0, solvent: str = "", - **kwargs + **kwargs, ) -> bool: """Execute evaporate action - 简化版 🌪️""" - + # 🔧 新增:确保time参数是数值类型 if isinstance(time, str): try: @@ -98,31 +113,31 @@ class VirtualRotavap: elif not isinstance(time, (int, float)): self.logger.error(f"❌ 时间参数类型无效: {type(time)},使用默认值180.0秒") time = 180.0 - + # 确保time是float类型; 并加速 time = float(time) / 10.0 - + # 🔧 简化处理:如果vessel就是设备自己,直接操作 if vessel == self.device_id: debug_print(f"🎯 在设备 {self.device_id} 上直接执行蒸发操作") actual_vessel = self.device_id else: actual_vessel = vessel - + # 参数预处理 if solvent: self.logger.info(f"🧪 识别到溶剂: {solvent}") # 根据溶剂调整参数 solvent_lower = solvent.lower() - if any(s in solvent_lower for s in ['water', 'aqueous']): + if any(s in solvent_lower for s in ["water", "aqueous"]): temp = max(temp, 80.0) pressure = max(pressure, 0.2) self.logger.info(f"💧 水系溶剂:调整参数 → 温度 {temp}°C, 压力 {pressure} bar") - elif any(s in solvent_lower for s in ['ethanol', 'methanol', 'acetone']): + elif any(s in solvent_lower for s in ["ethanol", "methanol", "acetone"]): temp = min(temp, 50.0) pressure = min(pressure, 0.05) self.logger.info(f"⚡ 易挥发溶剂:调整参数 → 温度 {temp}°C, 压力 {pressure} bar") - + self.logger.info(f"🌪️ 开始蒸发操作: {actual_vessel}") self.logger.info(f" 🥽 容器: {actual_vessel}") self.logger.info(f" 🌡️ 温度: {temp}°C") @@ -131,126 +146,140 @@ class VirtualRotavap: self.logger.info(f" 🌀 转速: {stir_speed} RPM") if solvent: self.logger.info(f" 🧪 溶剂: {solvent}") - + # 验证参数 if temp > self._max_temp or temp < 10.0: error_msg = f"🌡️ 温度 {temp}°C 超出范围 (10-{self._max_temp}°C) ⚠️" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"❌ 错误: 温度超出范围", - "rotavap_state": "Error", - "current_temp": 25.0, - "progress": 0.0, - "evaporated_volume": 0.0, - "message": error_msg - }) + self.data.update( + { + "status": f"❌ 错误: 温度超出范围", + "rotavap_state": "Error", + "current_temp": 25.0, + "progress": 0.0, + "evaporated_volume": 0.0, + "message": error_msg, + } + ) return False if stir_speed > self._max_rotation_speed or stir_speed < 10.0: error_msg = f"🌀 旋转速度 {stir_speed} RPM 超出范围 (10-{self._max_rotation_speed} RPM) ⚠️" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"❌ 错误: 转速超出范围", - "rotavap_state": "Error", - "current_temp": 25.0, - "progress": 0.0, - "evaporated_volume": 0.0, - "message": error_msg - }) + self.data.update( + { + "status": f"❌ 错误: 转速超出范围", + "rotavap_state": "Error", + "current_temp": 25.0, + "progress": 0.0, + "evaporated_volume": 0.0, + "message": error_msg, + } + ) return False if pressure < 0.01 or pressure > 1.0: error_msg = f"💨 真空度 {pressure} bar 超出范围 (0.01-1.0 bar) ⚠️" self.logger.error(f"❌ {error_msg}") - self.data.update({ - "status": f"❌ 错误: 压力超出范围", - "rotavap_state": "Error", - "current_temp": 25.0, - "progress": 0.0, - "evaporated_volume": 0.0, - "message": error_msg - }) + self.data.update( + { + "status": f"❌ 错误: 压力超出范围", + "rotavap_state": "Error", + "current_temp": 25.0, + "progress": 0.0, + "evaporated_volume": 0.0, + "message": error_msg, + } + ) return False # 开始蒸发 - 🔧 现在time已经确保是float类型 self.logger.info(f"🚀 启动蒸发程序! 预计用时 {time/60:.1f}分钟 ⏱️") - - self.data.update({ - "status": f"🌪️ 蒸发中: {actual_vessel}", - "rotavap_state": "Evaporating", - "current_temp": temp, - "target_temp": temp, - "rotation_speed": stir_speed, - "vacuum_pressure": pressure, - "remaining_time": time, - "progress": 0.0, - "evaporated_volume": 0.0, - "message": f"🌪️ Evaporating {actual_vessel} at {temp}°C, {pressure} bar, {stir_speed} RPM" - }) + + self.data.update( + { + "status": f"🌪️ 蒸发中: {actual_vessel}", + "rotavap_state": "Evaporating", + "current_temp": temp, + "target_temp": temp, + "rotation_speed": stir_speed, + "vacuum_pressure": pressure, + "remaining_time": time, + "progress": 0.0, + "evaporated_volume": 0.0, + "message": f"🌪️ Evaporating {actual_vessel} at {temp}°C, {pressure} bar, {stir_speed} RPM", + } + ) try: # 蒸发过程 - 实时更新进度 start_time = time_module.time() total_time = time last_logged_progress = 0 - + while True: current_time = time_module.time() elapsed = current_time - start_time remaining = max(0, total_time - elapsed) progress = min(100.0, (elapsed / total_time) * 100) - + # 模拟蒸发体积 - 根据溶剂类型调整 - if solvent and any(s in solvent.lower() for s in ['water', 'aqueous']): + if solvent and any(s in solvent.lower() for s in ["water", "aqueous"]): evaporated_vol = progress * 0.6 # 水系溶剂蒸发慢 - elif solvent and any(s in solvent.lower() for s in ['ethanol', 'methanol', 'acetone']): + elif solvent and any(s in solvent.lower() for s in ["ethanol", "methanol", "acetone"]): evaporated_vol = progress * 1.0 # 易挥发溶剂蒸发快 else: evaporated_vol = progress * 0.8 # 默认蒸发量 - + # 🔧 更新状态 - 确保包含所有必需字段 status_msg = f"🌪️ 蒸发中: {actual_vessel} | 🌡️ {temp}°C | 💨 {pressure} bar | 🌀 {stir_speed} RPM | 📊 {progress:.1f}% | ⏰ 剩余: {remaining:.0f}s" - - self.data.update({ - "remaining_time": remaining, - "progress": progress, - "evaporated_volume": evaporated_vol, - "current_temp": temp, - "status": status_msg, - "message": f"🌪️ Evaporating: {progress:.1f}% complete, 💧 {evaporated_vol:.1f}mL evaporated, ⏰ {remaining:.0f}s remaining" - }) - + + self.data.update( + { + "remaining_time": remaining, + "progress": progress, + "evaporated_volume": evaporated_vol, + "current_temp": temp, + "status": status_msg, + "message": f"🌪️ Evaporating: {progress:.1f}% complete, 💧 {evaporated_vol:.1f}mL evaporated, ⏰ {remaining:.0f}s remaining", + } + ) + # 进度日志(每25%打印一次) if progress >= 25 and int(progress) % 25 == 0 and int(progress) != last_logged_progress: - self.logger.info(f"📊 蒸发进度: {progress:.0f}% | 💧 已蒸发: {evaporated_vol:.1f}mL | ⏰ 剩余: {remaining:.0f}s ✨") + self.logger.info( + f"📊 蒸发进度: {progress:.0f}% | 💧 已蒸发: {evaporated_vol:.1f}mL | ⏰ 剩余: {remaining:.0f}s ✨" + ) last_logged_progress = int(progress) - + # 时间到了,退出循环 if remaining <= 0: break - + # 每秒更新一次 - await asyncio.sleep(1.0) - + await self._ros_node.sleep(1.0) + # 蒸发完成 - if solvent and any(s in solvent.lower() for s in ['water', 'aqueous']): + if solvent and any(s in solvent.lower() for s in ["water", "aqueous"]): final_evaporated = 60.0 # 水系溶剂 - elif solvent and any(s in solvent.lower() for s in ['ethanol', 'methanol', 'acetone']): + elif solvent and any(s in solvent.lower() for s in ["ethanol", "methanol", "acetone"]): final_evaporated = 100.0 # 易挥发溶剂 else: final_evaporated = 80.0 # 默认 - - self.data.update({ - "status": f"✅ 蒸发完成: {actual_vessel} | 💧 蒸发量: {final_evaporated:.1f}mL", - "rotavap_state": "Completed", - "evaporated_volume": final_evaporated, - "progress": 100.0, - "current_temp": temp, - "remaining_time": 0.0, - "rotation_speed": 0.0, - "vacuum_pressure": 1.0, - "message": f"✅ Evaporation completed: {final_evaporated}mL evaporated from {actual_vessel}" - }) + + self.data.update( + { + "status": f"✅ 蒸发完成: {actual_vessel} | 💧 蒸发量: {final_evaporated:.1f}mL", + "rotavap_state": "Completed", + "evaporated_volume": final_evaporated, + "progress": 100.0, + "current_temp": temp, + "remaining_time": 0.0, + "rotation_speed": 0.0, + "vacuum_pressure": 1.0, + "message": f"✅ Evaporation completed: {final_evaporated}mL evaporated from {actual_vessel}", + } + ) self.logger.info(f"🎉 蒸发操作完成! ✨") self.logger.info(f"📊 蒸发结果:") @@ -262,24 +291,26 @@ class VirtualRotavap: self.logger.info(f" ⏱️ 总用时: {total_time:.0f}s") if solvent: self.logger.info(f" 🧪 处理溶剂: {solvent} 🏁") - + return True except Exception as e: # 出错处理 error_msg = f"蒸发过程中发生错误: {str(e)} 💥" self.logger.error(f"❌ {error_msg}") - - self.data.update({ - "status": f"❌ 蒸发错误: {str(e)}", - "rotavap_state": "Error", - "current_temp": 25.0, - "progress": 0.0, - "evaporated_volume": 0.0, - "rotation_speed": 0.0, - "vacuum_pressure": 1.0, - "message": f"❌ Evaporation failed: {str(e)}" - }) + + self.data.update( + { + "status": f"❌ 蒸发错误: {str(e)}", + "rotavap_state": "Error", + "current_temp": 25.0, + "progress": 0.0, + "evaporated_volume": 0.0, + "rotation_speed": 0.0, + "vacuum_pressure": 1.0, + "message": f"❌ Evaporation failed: {str(e)}", + } + ) return False # === 核心状态属性 === diff --git a/unilabos/devices/virtual/virtual_separator.py b/unilabos/devices/virtual/virtual_separator.py index e1c46128..0f266ce1 100644 --- a/unilabos/devices/virtual/virtual_separator.py +++ b/unilabos/devices/virtual/virtual_separator.py @@ -2,9 +2,13 @@ import asyncio import logging from typing import Dict, Any, Optional +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualSeparator: """Virtual separator device for SeparateProtocol testing""" + + _ros_node: BaseROS2DeviceNode def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): # 处理可能的不同调用方式 @@ -35,6 +39,9 @@ class VirtualSeparator: for key, value in kwargs.items(): if key not in skip_keys and not hasattr(self, key): setattr(self, key, value) + + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node async def initialize(self) -> bool: """Initialize virtual separator""" @@ -119,14 +126,14 @@ class VirtualSeparator: for repeat in range(repeats): # 搅拌阶段 for progress in range(0, 51, 10): - await asyncio.sleep(simulation_time / (repeats * 10)) + await self._ros_node.sleep(simulation_time / (repeats * 10)) overall_progress = ((repeat * 100) + (progress * 0.5)) / repeats self.data["progress"] = overall_progress self.data["message"] = f"第{repeat+1}次分离 - 搅拌中 ({progress}%)" # 静置分相阶段 for progress in range(50, 101, 10): - await asyncio.sleep(simulation_time / (repeats * 10)) + await self._ros_node.sleep(simulation_time / (repeats * 10)) overall_progress = ((repeat * 100) + (progress * 0.5)) / repeats self.data["progress"] = overall_progress self.data["message"] = f"第{repeat+1}次分离 - 静置分相中 ({progress}%)" diff --git a/unilabos/devices/virtual/virtual_solenoid_valve.py b/unilabos/devices/virtual/virtual_solenoid_valve.py index e0194248..26970cbe 100644 --- a/unilabos/devices/virtual/virtual_solenoid_valve.py +++ b/unilabos/devices/virtual/virtual_solenoid_valve.py @@ -2,11 +2,16 @@ import time import asyncio from typing import Union +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualSolenoidValve: """ 虚拟电磁阀门 - 简单的开关型阀门,只有开启和关闭两个状态 """ + + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = None, config: dict = None, **kwargs): # 从配置中获取参数,提供默认值 if config is None: @@ -21,6 +26,9 @@ class VirtualSolenoidValve: self._status = "Idle" self._valve_state = "Closed" # "Open" or "Closed" self._is_open = False + + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node async def initialize(self) -> bool: """初始化设备""" @@ -63,7 +71,7 @@ class VirtualSolenoidValve: self._status = "Busy" # 模拟阀门响应时间 - await asyncio.sleep(self.response_time) + await self._ros_node.sleep(self.response_time) # 处理不同的命令格式 if isinstance(command, str): diff --git a/unilabos/devices/virtual/virtual_solid_dispenser.py b/unilabos/devices/virtual/virtual_solid_dispenser.py index f8c14a75..63182616 100644 --- a/unilabos/devices/virtual/virtual_solid_dispenser.py +++ b/unilabos/devices/virtual/virtual_solid_dispenser.py @@ -3,6 +3,8 @@ import logging import re from typing import Dict, Any, Optional +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualSolidDispenser: """ 虚拟固体粉末加样器 - 用于处理 Add Protocol 中的固体试剂添加 ⚗️ @@ -13,6 +15,8 @@ class VirtualSolidDispenser: - 简单反馈:成功/失败 + 消息 📊 """ + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): self.device_id = device_id or "virtual_solid_dispenser" self.config = config or {} @@ -32,6 +36,9 @@ class VirtualSolidDispenser: print(f"⚗️ === 虚拟固体分配器 {self.device_id} 创建成功! === ✨") print(f"📊 设备规格: 最大容量 {self.max_capacity}g | 精度 {self.precision}g 🎯") + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """初始化固体加样器 🚀""" self.logger.info(f"🔧 初始化固体分配器 {self.device_id} ✨") @@ -263,7 +270,7 @@ class VirtualSolidDispenser: for i in range(steps): progress = (i + 1) / steps * 100 - await asyncio.sleep(step_time) + await self._ros_node.sleep(step_time) if i % 2 == 0: # 每隔一步显示进度 self.logger.debug(f"📊 加样进度: {progress:.0f}% | {amount_emoji} 正在分配 {reagent}...") diff --git a/unilabos/devices/virtual/virtual_stirrer.py b/unilabos/devices/virtual/virtual_stirrer.py index cccf61ea..8e95617f 100644 --- a/unilabos/devices/virtual/virtual_stirrer.py +++ b/unilabos/devices/virtual/virtual_stirrer.py @@ -3,9 +3,13 @@ import logging import time as time_module from typing import Dict, Any +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualStirrer: """Virtual stirrer device for StirProtocol testing - 功能完整版 🌪️""" + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): # 处理可能的不同调用方式 if device_id is None and 'id' in kwargs: @@ -34,6 +38,9 @@ class VirtualStirrer: print(f"🌪️ === 虚拟搅拌器 {self.device_id} 已创建 === ✨") print(f"🔧 速度范围: {self._min_speed} ~ {self._max_speed} RPM | 📱 端口: {self.port}") + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """Initialize virtual stirrer 🚀""" self.logger.info(f"🔧 初始化虚拟搅拌器 {self.device_id} ✨") @@ -134,7 +141,7 @@ class VirtualStirrer: if remaining <= 0: break - await asyncio.sleep(1.0) + await self._ros_node.sleep(1.0) self.logger.info(f"✅ 搅拌阶段完成! 🌪️ {stir_speed} RPM × {stir_time}s") @@ -176,7 +183,7 @@ class VirtualStirrer: if remaining <= 0: break - await asyncio.sleep(1.0) + await self._ros_node.sleep(1.0) self.logger.info(f"✅ 沉降阶段完成! 🛑 静置 {settling_time}s") diff --git a/unilabos/devices/virtual/virtual_transferpump.py b/unilabos/devices/virtual/virtual_transferpump.py index 1187db5f..7b8eea86 100644 --- a/unilabos/devices/virtual/virtual_transferpump.py +++ b/unilabos/devices/virtual/virtual_transferpump.py @@ -4,6 +4,8 @@ from enum import Enum from typing import Union, Optional import logging +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + class VirtualPumpMode(Enum): Normal = 0 @@ -14,6 +16,8 @@ class VirtualPumpMode(Enum): class VirtualTransferPump: """虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰""" + _ros_node: BaseROS2DeviceNode + def __init__(self, device_id: str = None, config: dict = None, **kwargs): """ 初始化虚拟转移泵 @@ -53,6 +57,9 @@ class VirtualTransferPump: print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s") print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}") + def post_init(self, ros_node: BaseROS2DeviceNode): + self._ros_node = ros_node + async def initialize(self) -> bool: """初始化虚拟泵 🚀""" self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id} ✨") @@ -104,7 +111,7 @@ class VirtualTransferPump: async def _simulate_operation(self, duration: float): """模拟操作延时 ⏱️""" self._status = "Busy" - await asyncio.sleep(duration) + await self._ros_node.sleep(duration) self._status = "Idle" def _calculate_duration(self, volume: float, velocity: float = None) -> float: @@ -223,7 +230,7 @@ class VirtualTransferPump: # 等待一小步时间 if i < steps and step_duration > 0: - await asyncio.sleep(step_duration) + await self._ros_node.sleep(step_duration) else: # 移动距离很小,直接完成 self._position = target_position @@ -341,7 +348,7 @@ class VirtualTransferPump: # 短暂停顿 self.logger.debug("⏸️ 短暂停顿...") - await asyncio.sleep(0.1) + await self._ros_node.sleep(0.1) # 排液 await self.dispense(volume, dispense_velocity) From 1b2c0dbcd7ef673ab8ae4f06dbaa1668539663b7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:31:37 +0800 Subject: [PATCH 07/15] adjust with_children param --- unilabos/ros/nodes/base_device_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 2fc7ea7d..19503fdc 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -656,7 +656,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): ].call_async( SerialCommand.Request( command=json.dumps( - {"data": {"data": resources_uuid, "with_children": False}, "action": "get"} + {"data": {"data": resources_uuid, "with_children": True if action == "add" else "update"}, "action": "get"} ) ) ) # type: ignore From 30b202bea09821d049e27fc53cd799ab45acc8ff Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:46:27 +0800 Subject: [PATCH 08/15] disable slave connect websocket --- unilabos/app/main.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index c646518f..db15e2c6 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -375,22 +375,23 @@ def main(): args_dict["bridges"] = [] - # 获取通信客户端(仅支持WebSocket) - comm_client = get_communication_client() - - if "websocket" in args_dict["app_bridges"]: - args_dict["bridges"].append(comm_client) if "fastapi" in args_dict["app_bridges"]: args_dict["bridges"].append(http_client) - if "websocket" in args_dict["app_bridges"]: + # 获取通信客户端(仅支持WebSocket) + if BasicConfig.is_host_mode: + comm_client = get_communication_client() + if "websocket" in args_dict["app_bridges"]: + args_dict["bridges"].append(comm_client) + def _exit(signum, frame): + comm_client.stop() + sys.exit(0) - def _exit(signum, frame): - comm_client.stop() - sys.exit(0) + signal.signal(signal.SIGINT, _exit) + signal.signal(signal.SIGTERM, _exit) + comm_client.start() + else: + print_status("SlaveMode跳过Websocket连接") - signal.signal(signal.SIGINT, _exit) - signal.signal(signal.SIGTERM, _exit) - comm_client.start() args_dict["resources_mesh_config"] = {} args_dict["resources_edge_config"] = resource_edge_info # web visiualize 2D From d9dffc6bf88af790992bb3b68d7b33a385032547 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:52:09 +0800 Subject: [PATCH 09/15] correct remove_resource stats --- unilabos/ros/nodes/resource_tracker.py | 41 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index c958fe7b..fc3ca7a0 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -2,7 +2,7 @@ import traceback import uuid from pydantic import BaseModel, field_serializer, field_validator from pydantic import Field -from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING +from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union from unilabos.utils.log import logger @@ -927,7 +927,7 @@ class DeviceNodeResourceTracker(object): """ 递归遍历资源树,更新所有节点的uuid - Args:0 + Args: resource: 资源对象(可以是dict或实例) uuid_map: uuid映射字典,{old_uuid: new_uuid} @@ -952,6 +952,27 @@ class DeviceNodeResourceTracker(object): return self._traverse_and_process(resource, process) + def loop_gather_uuid(self, resource) -> List[str]: + """ + 递归遍历资源树,收集所有节点的uuid + + Args: + resource: 资源对象(可以是dict或实例) + + Returns: + 收集到的uuid列表 + """ + uuid_list = [] + + def process(res): + current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") + if current_uuid: + uuid_list.append(current_uuid) + return 0 + + self._traverse_and_process(resource, process) + return uuid_list + def _collect_uuid_mapping(self, resource): """ 递归收集资源的 uuid 映射到 uuid_to_resources @@ -972,7 +993,7 @@ class DeviceNodeResourceTracker(object): self._traverse_and_process(resource, process) - def _remove_uuid_mapping(self, resource): + def _remove_uuid_mapping(self, resource) -> int: """ 递归清除资源的 uuid 映射 @@ -985,9 +1006,10 @@ class DeviceNodeResourceTracker(object): if current_uuid and current_uuid in self.uuid_to_resources: self.uuid_to_resources.pop(current_uuid) logger.debug(f"移除资源UUID映射: {current_uuid} -> {res}") + return 1 return 0 - self._traverse_and_process(resource, process) + return self._traverse_and_process(resource, process) def parent_resource(self, resource): if id(resource) in self.resource2parent_resource: @@ -1042,13 +1064,12 @@ class DeviceNodeResourceTracker(object): removed = True break - if not removed: + # 递归清除uuid映射 + count = self._remove_uuid_mapping(resource) + if not count: logger.warning(f"尝试移除不存在的资源: {resource}") return False - # 递归清除uuid映射 - self._remove_uuid_mapping(resource) - # 清除 resource2parent_resource 中与该资源相关的映射 # 需要清除:1) 该资源作为 key 的映射 2) 该资源作为 value 的映射 keys_to_remove = [] @@ -1071,7 +1092,9 @@ class DeviceNodeResourceTracker(object): self.uuid_to_resources.clear() self.resource2parent_resource.clear() - def figure_resource(self, query_resource, try_mode=False): + def figure_resource( + self, query_resource: Union[List[Union[dict, "PLRResource"]], dict, "PLRResource"], try_mode=False + ) -> Union[List[Union[dict, "PLRResource", List[Union[dict, "PLRResource"]]]], dict, "PLRResource"]: if isinstance(query_resource, list): return [self.figure_resource(r, try_mode) for r in query_resource] elif ( From d897d70c3e7fd172710d7b4d38f0fe11ad9d20df Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:04:53 +0800 Subject: [PATCH 10/15] change uuid logger to trace level --- .../devices/liquid_handling/liquid_handler_abstract.py | 10 +++++----- unilabos/ros/nodes/resource_tracker.py | 8 +++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 32e370fe..6aefaf31 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -1,11 +1,11 @@ from __future__ import annotations -import re -import traceback -from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast -from collections import Counter + import asyncio import time -import pprint as pp +import traceback +from collections import Counter +from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast + from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod from pylabrobot.liquid_handling.standard import GripDirection diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index fc3ca7a0..085ce028 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -894,7 +894,7 @@ class DeviceNodeResourceTracker(object): new_uuid = name_to_uuid_map[resource_name] self.set_resource_uuid(res, new_uuid) self.uuid_to_resources[new_uuid] = res - logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}") + logger.trace(f"设置资源UUID: {resource_name} -> {new_uuid}") return 1 return 0 @@ -917,7 +917,8 @@ class DeviceNodeResourceTracker(object): if resource_name and resource_name in name_to_extra_map: extra = name_to_extra_map[resource_name] self.set_resource_extra(res, extra) - logger.debug(f"设置资源Extra: {resource_name} -> {extra}") + if len(extra): + logger.debug(f"设置资源Extra: {resource_name} -> {extra}") return 1 return 0 @@ -986,9 +987,10 @@ class DeviceNodeResourceTracker(object): if current_uuid: old = self.uuid_to_resources.get(current_uuid) self.uuid_to_resources[current_uuid] = res - logger.debug( + logger.trace( f"收集资源UUID映射: {current_uuid} -> {res} {'' if old is None else f'(覆盖旧值: {old})'}" ) + return 1 return 0 self._traverse_and_process(resource, process) From 5a0c2f98509b7b67f1f71e0a9d47463e14e064e3 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:11:27 +0800 Subject: [PATCH 11/15] enable slave mode --- unilabos/app/backend.py | 4 +- unilabos/app/register.py | 5 +- unilabos/ros/main_slave_run.py | 130 +++++++++++++----------- unilabos/ros/nodes/presets/host_node.py | 21 ++-- unilabos/ros/nodes/resource_tracker.py | 2 +- unilabos/utils/log.py | 3 +- 6 files changed, 90 insertions(+), 75 deletions(-) diff --git a/unilabos/app/backend.py b/unilabos/app/backend.py index d43b9544..b2bc0af2 100644 --- a/unilabos/app/backend.py +++ b/unilabos/app/backend.py @@ -13,7 +13,7 @@ def start_backend( graph=None, controllers_config: dict = {}, bridges=[], - without_host: bool = False, + is_slave: bool = False, visual: str = "None", resources_mesh_config: dict = {}, **kwargs, @@ -32,7 +32,7 @@ def start_backend( raise ValueError(f"Unsupported backend: {backend}") backend_thread = threading.Thread( - target=main if not without_host else slave, + target=main if not is_slave else slave, args=( devices_config, resources_config, diff --git a/unilabos/app/register.py b/unilabos/app/register.py index f456183d..633df98f 100644 --- a/unilabos/app/register.py +++ b/unilabos/app/register.py @@ -1,11 +1,12 @@ import json import time +from typing import Optional, Tuple, Dict, Any from unilabos.utils.log import logger from unilabos.utils.type_check import TypeEncoder -def register_devices_and_resources(lab_registry): +def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: """ 注册设备和资源到服务器(仅支持HTTP) """ @@ -28,6 +29,8 @@ def register_devices_and_resources(lab_registry): resources_to_register[resource_info["id"]] = resource_info logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}") + if gather_only: + return devices_to_register, resources_to_register # 注册设备 if devices_to_register: try: diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index d9ad3682..1ded6da1 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -6,11 +6,12 @@ from typing import Optional, Dict, Any, List import rclpy from unilabos_msgs.srv._serial_command import SerialCommand_Response +from unilabos.app.register import register_devices_and_resources from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher from unilabos_msgs.srv import SerialCommand # type: ignore -from rclpy.executors import MultiThreadedExecutor, SingleThreadedExecutor +from rclpy.executors import MultiThreadedExecutor from rclpy.node import Node from rclpy.timer import Timer @@ -108,66 +109,51 @@ def slave( rclpy_init_args: List[str] = ["--log-level", "debug"], ) -> None: """从节点函数""" + # 1. 初始化 ROS2 if not rclpy.ok(): rclpy.init(args=rclpy_init_args) executor = rclpy.__executor if not executor: executor = rclpy.__executor = MultiThreadedExecutor() - devices_instances = {} - for device_config in devices_config.root_nodes: - device_id = device_config.res_content.id - if device_config.res_content.type != "device": - d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) - devices_instances[device_id] = d - # 默认初始化 - # if d is not None and isinstance(d, Node): - # executor.add_node(d) - # else: - # print(f"Warning: Device {device_id} could not be initialized or is not a valid Node") - n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[]) - executor.add_node(n) - - if visual != "disable": - from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher - - resource_mesh_manager = ResourceMeshManager( - resources_mesh_config, - resources_config, # type: ignore FIXME - resource_tracker=DeviceNodeResourceTracker(), - device_id="resource_mesh_manager", - ) - joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker()) - - executor.add_node(resource_mesh_manager) - executor.add_node(joint_republisher) + # 1.5 启动 executor 线程 thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") thread.start() + # 2. 创建 Slave Machine Node + n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[]) + executor.add_node(n) + + # 3. 向 Host 报送节点信息和物料,获取 UUID 映射 + uuid_mapping = {} if not BasicConfig.slave_no_host: + # 3.1 报送节点信息 sclient = n.create_client(SerialCommand, "/node_info_update") sclient.wait_for_service() + registry_config = {} + devices_to_register, resources_to_register = register_devices_and_resources(lab_registry, True) + registry_config.update(devices_to_register) + registry_config.update(resources_to_register) request = SerialCommand.Request() request.command = json.dumps( { "machine_name": BasicConfig.machine_name, "type": "slave", "devices_config": devices_config.dump(), - "registry_config": lab_registry.obtain_registry_device_info(), + "registry_config": registry_config, }, ensure_ascii=False, cls=TypeEncoder, ) - response = sclient.call_async(request).result() + sclient.call_async(request).result() logger.info(f"Slave node info updated.") - # 使用新的 c2s_update_resource_tree 服务 - rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree") - rclient.wait_for_service() - - # 序列化 ResourceTreeSet 为 JSON + # 3.2 报送物料树,获取 UUID 映射 if resources_config: + rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree") + rclient.wait_for_service() + request = SerialCommand.Request() request.command = json.dumps( { @@ -180,35 +166,61 @@ def slave( }, ensure_ascii=False, ) - tree_response: SerialCommand_Response = rclient.call_async(request).result() + tree_response: SerialCommand_Response = rclient.call(request) uuid_mapping = json.loads(tree_response.response) - # 创建反向映射:new_uuid -> old_uuid - reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()} - for node in resources_config.root_nodes: - if node.res_content.type == "device": - for sub_node in node.children: - # 只有二级子设备 - if sub_node.res_content.type != "device": - device_tracker = devices_instances[node.res_content.id].resource_tracker - # sub_node.res_content.uuid 已经是新UUID,需要用旧UUID去查找 - old_uuid = reverse_uuid_mapping.get(sub_node.res_content.uuid) - if old_uuid: - # 找到旧UUID,使用UUID查找 - resource_instance = device_tracker.figure_resource({"uuid": old_uuid}) - else: - # 未找到旧UUID,使用name查找 - resource_instance = device_tracker.figure_resource({"name": sub_node.res_content.name}) - device_tracker.loop_update_uuid(resource_instance, uuid_mapping) + logger.info(f"Slave resource tree added. UUID mapping: {len(uuid_mapping)} nodes") + + # 3.3 使用 UUID 映射更新 resources_config 的 UUID(参考 client.py 逻辑) + old_uuids = {node.res_content.uuid: node for node in resources_config.all_nodes} + for old_uuid, node in old_uuids.items(): + if old_uuid in uuid_mapping: + new_uuid = uuid_mapping[old_uuid] + node.res_content.uuid = new_uuid + # 更新所有子节点的 parent_uuid + for child in node.children: + child.res_content.parent_uuid = new_uuid else: - logger.error("Slave模式不允许新增非设备节点下的物料") - continue - if tree_response: - logger.info(f"Slave resource tree added. Response: {tree_response.response}") - else: - logger.warning("Slave resource tree add response is None") + logger.warning(f"资源UUID未更新: {old_uuid}") else: logger.info("No resources to add.") + # 4. 初始化所有设备实例(此时 resources_config 的 UUID 已更新) + devices_instances = {} + for device_config in devices_config.root_nodes: + device_id = device_config.res_content.id + if device_config.res_content.type == "device": + d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) + if d is not None: + devices_instances[device_id] = d + logger.info(f"Device {device_id} initialized.") + else: + logger.warning(f"Device {device_id} initialization failed.") + + # 5. 如果启用可视化,创建可视化相关节点 + if visual != "disable": + from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher + + # 将 ResourceTreeSet 转换为 list 用于 visual 组件 + resources_list = ( + [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] + if resources_config + else [] + ) + resource_mesh_manager = ResourceMeshManager( + resources_mesh_config, + resources_list, + resource_tracker=DeviceNodeResourceTracker(), + device_id="resource_mesh_manager", + ) + joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker()) + lh_joint_pub = LiquidHandlerJointPublisher( + resources_config=resources_list, resource_tracker=DeviceNodeResourceTracker() + ) + executor.add_node(resource_mesh_manager) + executor.add_node(joint_republisher) + executor.add_node(lh_joint_pub) + + # 7. 保持运行 while True: time.sleep(1) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 346cf9c2..5c43a5d0 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -878,11 +878,10 @@ class HostNode(BaseROS2DeviceNode): success = False uuid_mapping = {} if len(self.bridges) > 0: - from unilabos.app.web.client import HTTPClient + from unilabos.app.web.client import HTTPClient, http_client - client: HTTPClient = self.bridges[-1] resource_start_time = time.time() - uuid_mapping = client.resource_tree_add(resource_tree_set, mount_uuid, first_add) + uuid_mapping = http_client.resource_tree_add(resource_tree_set, mount_uuid, first_add) success = True resource_end_time = time.time() self.lab_logger().info( @@ -990,9 +989,10 @@ class HostNode(BaseROS2DeviceNode): """ 更新节点信息回调 """ - self.lab_logger().info(f"[Host Node] Node info update request received: {request}") + # self.lab_logger().info(f"[Host Node] Node info update request received: {request}") try: from unilabos.app.communication import get_communication_client + from unilabos.app.web.client import HTTPClient, http_client info = json.loads(request.command) if "SYNC_SLAVE_NODE_INFO" in info: @@ -1001,10 +1001,10 @@ class HostNode(BaseROS2DeviceNode): edge_device_id = info["edge_device_id"] self.device_machine_names[edge_device_id] = machine_name else: - comm_client = get_communication_client() - registry_config = info["registry_config"] - for device_config in registry_config: - comm_client.publish_registry(device_config["id"], device_config) + devices_config = info.pop("devices_config") + registry_config = info.pop("registry_config") + if registry_config: + http_client.resource_registry({"resources": registry_config}) self.lab_logger().debug(f"[Host Node] Node info update: {info}") response.response = "OK" except Exception as e: @@ -1030,10 +1030,9 @@ class HostNode(BaseROS2DeviceNode): success = False if len(self.bridges) > 0: # 边的提交待定 - from unilabos.app.web.client import HTTPClient + from unilabos.app.web.client import HTTPClient, http_client - client: HTTPClient = self.bridges[-1] - r = client.resource_add(add_schema(resources)) + r = http_client.resource_add(add_schema(resources)) success = bool(r) response.success = success diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 085ce028..ce23a5be 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1007,7 +1007,7 @@ class DeviceNodeResourceTracker(object): current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") if current_uuid and current_uuid in self.uuid_to_resources: self.uuid_to_resources.pop(current_uuid) - logger.debug(f"移除资源UUID映射: {current_uuid} -> {res}") + logger.trace(f"移除资源UUID映射: {current_uuid} -> {res}") return 1 return 0 diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index 74442a62..3894233b 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -191,7 +191,8 @@ def configure_logger(loglevel=None): # 添加处理器到根日志记录器 root_logger.addHandler(console_handler) - + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("urllib3").setLevel(logging.INFO) # 配置日志系统 configure_logger() From 85dc46cd3833bed48c4fd149a7d32f94f0bfa1a5 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:57:16 +0800 Subject: [PATCH 12/15] support name change during materials change --- unilabos/ros/nodes/base_device_node.py | 221 ++++++++++++++++++------- 1 file changed, 160 insertions(+), 61 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 19503fdc..febc95f2 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -638,6 +638,147 @@ class BaseROS2DeviceNode(Node, Generic[T]): - remove: 从资源树中移除资源 """ from pylabrobot.resources.resource import Resource as ResourcePLR + + def _handle_add( + plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理资源添加操作的内部函数 + + Args: + plr_resources: PLR资源列表 + tree_set: 资源树集合 + additional_add_params: 额外的添加参数 + + Returns: + 操作结果字典 + """ + for plr_resource, tree in zip(plr_resources, tree_set.trees): + self.resource_tracker.add_resource(plr_resource) + self.transfer_to_new_resource(plr_resource, tree, additional_add_params) + + func = getattr(self.driver_instance, "resource_tree_add", None) + if callable(func): + func(plr_resources) + + return {"success": True, "action": "add"} + + def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]: + """ + 处理资源移除操作的内部函数 + + Args: + resources_uuid: 要移除的资源UUID列表 + + Returns: + 操作结果字典,包含移除的资源列表 + """ + found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource( + [{"uuid": uid} for uid in resources_uuid], try_mode=True + ) + found_plr_resources = [] + other_plr_resources = [] + + for found_resource in found_resources: + for resource in found_resource: + if issubclass(resource.__class__, ResourcePLR): + found_plr_resources.append(resource) + else: + other_plr_resources.append(resource) + + # 调用driver的remove回调 + func = getattr(self.driver_instance, "resource_tree_remove", None) + if callable(func): + func(found_plr_resources) + + # 从parent卸载并从tracker移除 + for plr_resource in found_plr_resources: + if plr_resource.parent is not None: + plr_resource.parent.unassign_child_resource(plr_resource) + self.resource_tracker.remove_resource(plr_resource) + self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") + + for other_plr_resource in other_plr_resources: + self.resource_tracker.remove_resource(other_plr_resource) + self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") + + return { + "success": True, + "action": "remove", + "removed_plr": found_plr_resources, + "removed_other": other_plr_resources, + } + + def _handle_update( + plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理资源更新操作的内部函数 + + Args: + plr_resources: PLR资源列表(包含新状态) + tree_set: 资源树集合 + additional_add_params: 额外的参数 + + Returns: + 操作结果字典 + """ + for plr_resource, tree in zip(plr_resources, tree_set.trees): + states = plr_resource.serialize_all_state() + original_instance: ResourcePLR = self.resource_tracker.figure_resource( + {"uuid": tree.root_node.res_content.uuid}, try_mode=False + ) + + # Update操作中包含改名:需要先remove再add + if original_instance.name != plr_resource.name: + old_name = original_instance.name + new_name = plr_resource.name + self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") + + # 收集所有相关的uuid(包括子节点) + all_uuids = self.resource_tracker.loop_gather_uuid(original_instance) + _handle_remove(all_uuids) + original_instance.name = new_name + _handle_add([original_instance], tree_set, additional_add_params) + + self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") + continue + + # 常规更新:不涉及改名 + original_parent_resource = original_instance.parent + original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) + target_parent_resource_uuid = tree.root_node.res_content.uuid_parent + + self.lab_logger().info( + f"物料{original_instance} 原始父节点{original_parent_resource_uuid} " + f"目标父节点{target_parent_resource_uuid} 更新" + ) + + # 更新extra + if getattr(plr_resource, "unilabos_extra", None) is not None: + original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501 + + # 如果父节点变化,需要重新挂载 + if ( + original_parent_resource_uuid != target_parent_resource_uuid + and original_parent_resource is not None + ): + self.transfer_to_new_resource(original_instance, tree, additional_add_params) + + # 加载状态 + original_instance.load_all_state(states) + child_count = len(original_instance.get_all_children()) + self.lab_logger().info( + f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个" + ) + + # 调用driver的update回调 + func = getattr(self.driver_instance, "resource_tree_update", None) + if callable(func): + func(plr_resources) + + return {"success": True, "action": "update"} + try: data = json.loads(req.command) results = [] @@ -664,68 +805,20 @@ class BaseROS2DeviceNode(Node, Generic[T]): tree_set = ResourceTreeSet.from_raw_list(raw_nodes) try: if action == "add": - # 添加资源到资源跟踪器 + if tree_set is None: + raise ValueError("tree_set不能为None") plr_resources = tree_set.to_plr_resources() - for plr_resource, tree in zip(plr_resources, tree_set.trees): - self.resource_tracker.add_resource(plr_resource) - self.transfer_to_new_resource(plr_resource, tree, additional_add_params) - func = getattr(self.driver_instance, "resource_tree_add", None) - if callable(func): - func(plr_resources) - results.append({"success": True, "action": "add"}) + result = _handle_add(plr_resources, tree_set, additional_add_params) + results.append(result) elif action == "update": - # 更新资源 + if tree_set is None: + raise ValueError("tree_set不能为None") plr_resources = tree_set.to_plr_resources() - for plr_resource, tree in zip(plr_resources, tree_set.trees): - states = plr_resource.serialize_all_state() - original_instance: ResourcePLR = self.resource_tracker.figure_resource( - {"uuid": tree.root_node.res_content.uuid}, try_mode=False - ) - original_parent_resource = original_instance.parent - original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) - target_parent_resource_uuid = tree.root_node.res_content.uuid_parent - self.lab_logger().info( - f"物料{original_instance} 原始父节点{original_parent_resource_uuid} 目标父节点{target_parent_resource_uuid} 更新" - ) - # todo: 对extra进行update - if getattr(plr_resource, "unilabos_extra", None) is not None: - original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") - if original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None: - self.transfer_to_new_resource(original_instance, tree, additional_add_params) - original_instance.load_all_state(states) - self.lab_logger().info( - f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())} 个" - ) - - func = getattr(self.driver_instance, "resource_tree_update", None) - if callable(func): - func(plr_resources) - results.append({"success": True, "action": "update"}) + result = _handle_update(plr_resources, tree_set, additional_add_params) + results.append(result) elif action == "remove": - # 移除资源 - found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource( - [{"uuid": uid} for uid in resources_uuid], try_mode=True - ) - found_plr_resources = [] - other_plr_resources = [] - for found_resource in found_resources: - for resource in found_resource: - if issubclass(resource.__class__, ResourcePLR): - found_plr_resources.append(resource) - else: - other_plr_resources.append(resource) - func = getattr(self.driver_instance, "resource_tree_remove", None) - if callable(func): - func(found_plr_resources) - for plr_resource in found_plr_resources: - if plr_resource.parent is not None: - plr_resource.parent.unassign_child_resource(plr_resource) - self.resource_tracker.remove_resource(plr_resource) - self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") - for other_plr_resource in other_plr_resources: - self.resource_tracker.remove_resource(other_plr_resource) - self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") - results.append({"success": True, "action": "remove"}) + result = _handle_remove(resources_uuid) + results.append(result) except Exception as e: error_msg = f"Error processing {action} operation: {str(e)}" self.lab_logger().error(f"[Resource Tree Update] {error_msg}") @@ -1004,9 +1097,14 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 通过资源跟踪器获取本地实例 final_resources = queried_resources if is_sequence else queried_resources[0] - final_resources = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) if not is_sequence else [ - self.resource_tracker.figure_resource({"name": res.name}, try_mode=False) for res in queried_resources - ] + final_resources = ( + self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) + if not is_sequence + else [ + self.resource_tracker.figure_resource({"name": res.name}, try_mode=False) + for res in queried_resources + ] + ) action_kwargs[k] = final_resources except Exception as e: @@ -1227,6 +1325,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): raise JsonCommandInitError( f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" ) + def _convert_resource_sync(self, resource_data: Dict[str, Any]): """同步转换资源数据为实例""" # 创建资源查询请求 From d7494ca45819bfda65db9098ef57ecf972c75499 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 22:12:54 +0800 Subject: [PATCH 13/15] fix json dumps --- unilabos/ros/nodes/base_device_node.py | 10 +++----- unilabos/ros/nodes/presets/host_node.py | 33 ++++++++----------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index febc95f2..e52ea80d 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -705,8 +705,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): return { "success": True, "action": "remove", - "removed_plr": found_plr_resources, - "removed_other": other_plr_resources, + # "removed_plr": found_plr_resources, + # "removed_other": other_plr_resources, } def _handle_update( @@ -736,13 +736,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") # 收集所有相关的uuid(包括子节点) - all_uuids = self.resource_tracker.loop_gather_uuid(original_instance) - _handle_remove(all_uuids) + _handle_remove([original_instance.unilabos_uuid]) original_instance.name = new_name _handle_add([original_instance], tree_set, additional_add_params) self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") - continue # 常规更新:不涉及改名 original_parent_resource = original_instance.parent @@ -827,7 +825,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 返回处理结果 result_json = {"results": results, "total": len(data)} - res.response = json.dumps(result_json, ensure_ascii=False) + res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder) self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations") except json.JSONDecodeError as e: diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 5c43a5d0..7a8806d4 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -165,29 +165,16 @@ class HostNode(BaseROS2DeviceNode): # resources_config 的 root node 是 # # 创建反向映射:new_uuid -> old_uuid # reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()} - # for tree in resources_config.trees: - # node = tree.root_node - # if node.res_content.type == "device": - # if node.res_content.id == "host_node": - # continue - # # slave节点走c2s更新接口,拿到add自行update uuid - # device_tracker = self.devices_instances[node.res_content.id].resource_tracker - # old_uuid = reverse_uuid_mapping.get(node.res_content.uuid) - # if old_uuid: - # # 找到旧UUID,使用UUID查找 - # resource_instance = device_tracker.uuid_to_resources.get(old_uuid) - # else: - # # 未找到旧UUID,使用name查找 - # resource_instance = device_tracker.figure_resource( - # {"name": node.res_content.name} - # ) - # device_tracker.loop_update_uuid(resource_instance, uuid_mapping) - # else: - # try: - # for plr_resource in ResourceTreeSet([tree]).to_plr_resources(): - # self.resource_tracker.add_resource(plr_resource) - # except Exception as ex: - # self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!") + for tree in resources_config.trees: + node = tree.root_node + if node.res_content.type == "device": + continue + else: + try: + for plr_resource in ResourceTreeSet([tree]).to_plr_resources(): + self._resource_tracker.add_resource(plr_resource) + except Exception as ex: + self.lab_logger().warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!") except Exception as ex: logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}") # 初始化Node基类,传递空参数覆盖列表 From 9bba4620b74b10dd499d42cc9b4663dddbecccf9 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 22:14:43 +0800 Subject: [PATCH 14/15] fix resource_get param --- unilabos/ros/nodes/base_device_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index e52ea80d..7025e775 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -795,7 +795,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): ].call_async( SerialCommand.Request( command=json.dumps( - {"data": {"data": resources_uuid, "with_children": True if action == "add" else "update"}, "action": "get"} + {"data": {"data": resources_uuid, "with_children": True if action == "add" else False}, "action": "get"} ) ) ) # type: ignore From f872d3ef5698be3f01f642f714f1df6eee3b3e40 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Sun, 9 Nov 2025 01:00:05 +0800 Subject: [PATCH 15/15] add electrode_sheets definition, and fix magazines --- .../coin_cell_assembly/YB_YH_materials.py | 124 ++------ unilabos/resources/battery/electrode_sheet.py | 179 ++++++++++++ unilabos/resources/battery/magazine.py | 267 +++++++++++------- 3 files changed, 354 insertions(+), 216 deletions(-) create mode 100644 unilabos/resources/battery/electrode_sheet.py diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py index 8bb0a8de..d5f447c1 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -18,70 +18,11 @@ from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash from pylabrobot.resources.utils import create_ordered_items_2d -from unilabos.resources.battery.magazine import MagazineHolder_1, MagazineHolder_2, MagazineHolder_4, MagazineHolder_6 +from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier -class ElectrodeSheetState(TypedDict): - diameter: float # 直径 (mm) - thickness: float # 厚度 (mm) - mass: float # 质量 (g) - material_type: str # 材料类型(正极、负极、隔膜、弹片、垫片、铝箔等) - height: float - electrolyte_name: str - data_electrolyte_code: str - open_circuit_voltage: float - assembly_pressure: float - electrolyte_volume: float - info: Optional[str] # 附加信息 - -class ElectrodeSheet(Resource): - """极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料""" - - def __init__( - self, - name: str = "极片", - size_x=10, - size_y=10, - size_z=10, - category: str = "electrode_sheet", - model: Optional[str] = None, - ): - """初始化极片 - - Args: - name: 极片名称 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState( - diameter=14, - thickness=0.1, - mass=0.5, - material_type="copper", - info=None - ) - - # TODO: 这个还要不要?给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 # TODO: 这个应该只能放一个极片 class MaterialHoleState(TypedDict): @@ -477,13 +418,13 @@ def TipBox64( size_x: float = 127.8, size_y: float = 85.5, size_z: float = 60.0, - category: str = "tip_box_64", + category: str = "tip_rack", model: Optional[str] = None, ): """64孔枪头盒类""" from pylabrobot.resources.tip import Tip - # 创建8x8=64个枪头位 + # 创建12x8=96个枪头位 def make_tip(): return Tip( has_filter=False, @@ -508,17 +449,19 @@ def TipBox64( ) idx_available = list(range(0, 32)) + list(range(64, 96)) tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available} - return TipRack( + tip_rack = TipRack( name=name, size_x=size_x, size_y=size_y, size_z=size_z, - ordered_items=tip_spots_available, + # ordered_items=tip_spots_available, + ordered_items=tip_spots, category=category, model=model, - with_tips=True, + with_tips=False, ) - + tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头 + return tip_rack class WasteTipBoxstate(TypedDict): @@ -629,58 +572,23 @@ class CoincellDeck(Deck): def setup(self) -> None: """设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置""" # ====================================== 子弹夹 ============================================ - # 铝箔(1个洞位) - lvbo_zip = MagazineHolder_1("铝箔弹夹", 80, 80, 10) - self.assign_child_resource(lvbo_zip, Coordinate(x=2737.0, y=301.0, z=0)) # 正极片(4个洞位,2x2布局) - zhengji_zip = MagazineHolder_4("正极弹夹", 80, 80, 10) + zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹") self.assign_child_resource(zhengji_zip, Coordinate(x=2799.0, y=356.0, z=0)) - # 正极壳(4个洞位,2x2布局) - zhengjike_zip = MagazineHolder_4("正极壳弹夹", 80, 80, 10) + # 正极壳、平垫片(6个洞位,2x2+2布局) + zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹") self.assign_child_resource(zhengjike_zip, Coordinate(x=2586.0, y=1143.0, z=0)) - # 垫片(2个洞位,1x2布局) - danpian_zip = MagazineHolder_2("垫片弹夹", 80, 80, 10) - self.assign_child_resource(danpian_zip, Coordinate(x=2690.0, y=1141.0, z=0)) - - # 负极壳(4个洞位,2x2布局) - fujike_zip = MagazineHolder_4("负极壳弹夹", 80, 80, 10) + # 负极壳、弹垫片(6个洞位,2x2+2布局) + fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹") self.assign_child_resource(fujike_zip, Coordinate(x=2492.0, y=1144.0, z=0)) - # 弹片(2个洞位,1x2布局) - tanpian_zip = MagazineHolder_2("弹片弹夹", 80, 80, 10) - self.assign_child_resource(tanpian_zip, Coordinate(x=2492.0, y=1139.0, z=0)) - # 成品弹夹(6个洞位,3x2布局) - chengpindanjia_zip = MagazineHolder_6("成品弹夹", 80, 80, 10) + chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹") self.assign_child_resource(chengpindanjia_zip, Coordinate(x=3112.0, y=1295.0, z=0)) - # 为子弹夹添加极片 - for i in range(1): # MagazineHolder_1 有1个洞位 - lvbo = ElectrodeSheet(name=f"铝箔_{i}", size_x=12, size_y=12, size_z=0.1) - lvbo_zip.children[i].assign_child_resource(lvbo, location=None) - for i in range(4): # MagazineHolder_4 有4个洞位 - zhengji = ElectrodeSheet(name=f"正极_{i}", size_x=12, size_y=12, size_z=0.1) - zhengji_zip.children[i].assign_child_resource(zhengji, location=None) - for i in range(4): # MagazineHolder_4 有4个洞位 - zhengjike = ElectrodeSheet(name=f"正极壳_{i}", size_x=12, size_y=12, size_z=0.1) - zhengjike_zip.children[i].assign_child_resource(zhengjike, location=None) - for i in range(2): # MagazineHolder_2 有2个洞位 - danpian = ElectrodeSheet(name=f"垫片_{i}", size_x=12, size_y=12, size_z=0.1) - danpian_zip.children[i].assign_child_resource(danpian, location=None) - for i in range(4): # MagazineHolder_4 有4个洞位 - fujike = ElectrodeSheet(name=f"负极壳_{i}", size_x=12, size_y=12, size_z=0.1) - fujike_zip.children[i].assign_child_resource(fujike, location=None) - for i in range(2): # MagazineHolder_2 有2个洞位 - tanpian = ElectrodeSheet(name=f"弹片_{i}", size_x=12, size_y=12, size_z=0.1) - tanpian_zip.children[i].assign_child_resource(tanpian, location=None) - # for i in range(6): # MagazineHolder_6 有6个洞位 - # chengpindanjia = ElectrodeSheet(name=f"成品弹夹_{i}", size_x=12, size_y=12, size_z=0.1) - # chengpindanjia_zip.children[i].assign_child_resource(chengpindanjia, location=None) - - # ====================================== 物料板 ============================================ # 创建物料板(料盘carrier)- 4x4布局 # 负极料盘 @@ -699,7 +607,7 @@ class CoincellDeck(Deck): # ====================================== 瓶架、移液枪 ============================================ # 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒 - # 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写 + # 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板 # bottle_rack_3x4 = BottleRack( # name="bottle_rack_3x4", diff --git a/unilabos/resources/battery/electrode_sheet.py b/unilabos/resources/battery/electrode_sheet.py new file mode 100644 index 00000000..e86af24a --- /dev/null +++ b/unilabos/resources/battery/electrode_sheet.py @@ -0,0 +1,179 @@ +from typing import Any, Dict, Optional, TypedDict + +from pylabrobot.resources import Resource as ResourcePLR +from pylabrobot.resources import Container + + +electrode_colors = { + "PositiveCan": "#ff0000", + "PositiveElectrode": "#cc3333", + "NegativeCan": "#000000", + "NegativeElectrode": "#666666", + "SpringWasher": "#8b7355", + "FlatWasher": "a9a9a9", + "AluminumFoil": "#ffcccc", + "Battery": "#00ff00", +} + +class ElectrodeSheetState(TypedDict): + mass: float # 质量 (g) + material_type: str # 材料类型(铜、铝、不锈钢、弹簧钢等) + color: str # 材料类型对应的颜色 + + +class ElectrodeSheet(ResourcePLR): + """极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料""" + + def __init__( + self, + name: str = "极片", + size_x=10, + size_y=10, + size_z=10, + category: str = "electrode_sheet", + model: Optional[str] = None, + ): + """初始化极片 + + Args: + name: 极片名称 + category: 类别 + model: 型号 + """ + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + ) + self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState( + diameter=14, + thickness=0.1, + mass=0.5, + material_type="copper", + info=None + ) + + # TODO: 这个还要不要?给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 PositiveCan(name: str) -> ElectrodeSheet: + """创建正极壳""" + sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=3.0, model="PositiveCan") + sheet.load_state({"material_type": "aluminum", "color": electrode_colors["PositiveCan"]}) + return sheet + + +def PositiveElectrode(name: str) -> ElectrodeSheet: + """创建正极片""" + sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="PositiveElectrode") + sheet.load_state({"material_type": "positive_electrode", "color": electrode_colors["PositiveElectrode"]}) + return sheet + + +def NegativeCan(name: str) -> ElectrodeSheet: + """创建负极壳""" + sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=2.0, model="NegativeCan") + sheet.load_state({"material_type": "steel", "color": electrode_colors["NegativeCan"]}) + return sheet + + +def NegativeElectrode(name: str) -> ElectrodeSheet: + """创建负极片""" + sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="NegativeElectrode") + sheet.load_state({"material_type": "negative_electrode", "color": electrode_colors["NegativeElectrode"]}) + return sheet + + +def SpringWasher(name: str) -> ElectrodeSheet: + """创建弹片""" + sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.5, model="SpringWasher") + sheet.load_state({"material_type": "spring_steel", "color": electrode_colors["SpringWasher"]}) + return sheet + + +def FlatWasher(name: str) -> ElectrodeSheet: + """创建垫片""" + sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.2, model="FlatWasher") + sheet.load_state({"material_type": "steel", "color": electrode_colors["FlatWasher"]}) + return sheet + + +def AluminumFoil(name: str) -> ElectrodeSheet: + """创建铝箔""" + sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.05, model="AluminumFoil") + sheet.load_state({"material_type": "aluminum", "color": electrode_colors["AluminumFoil"]}) + return sheet + + +class BatteryState(TypedDict): + color: str # 材料类型对应的颜色 + electrolyte_name: str + data_electrolyte_code: str + open_circuit_voltage: float + assembly_pressure: float + electrolyte_volume: float + + info: Optional[str] # 附加信息 + + +class Battery(Container): + """电池类 - 包含组装好的电池""" + + def __init__( + self, + name: str = "电池", + size_x=12, + size_y=12, + size_z=6, + category: str = "battery", + model: Optional[str] = None, + ): + """初始化电池 + + Args: + name: 电池名称 + category: 类别 + model: 型号 + """ + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + ) + self._unilabos_state: BatteryState = BatteryState( + color=electrode_colors["Battery"], + electrolyte_name="无", + data_electrolyte_code="", + open_circuit_voltage=0.0, + assembly_pressure=0.0, + electrolyte_volume=0.0, + info=None + ) + + 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 \ No newline at end of file diff --git a/unilabos/resources/battery/magazine.py b/unilabos/resources/battery/magazine.py index a5a15ccc..f8d24447 100644 --- a/unilabos/resources/battery/magazine.py +++ b/unilabos/resources/battery/magazine.py @@ -1,10 +1,18 @@ -from typing import Dict, List, Optional, OrderedDict, Union +from typing import Dict, List, Optional, OrderedDict, Union, Callable import math from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources import Resource, ResourceStack, ItemizedResource from pylabrobot.resources.carrier import create_homogeneous_resources +from unilabos.resources.battery.electrode_sheet import ( + PositiveCan, PositiveElectrode, + NegativeCan, NegativeElectrode, + SpringWasher, FlatWasher, + AluminumFoil, + Battery +) + class Magazine(ResourceStack): """子弹夹洞位类""" @@ -32,6 +40,18 @@ class Magazine(ResourceStack): ) self.max_sheets = max_sheets + @property + def size_x(self) -> float: + return self.get_size_x() + + @property + def size_y(self) -> float: + return self.get_size_y() + + @property + def size_z(self) -> float: + return self.get_size_z() + class MagazineHolder(ItemizedResource): """子弹夹类 - 有多个洞位,每个洞位放多个极片""" @@ -98,6 +118,7 @@ def magazine_factory( size_y: float, size_z: float, locations: List[Coordinate], + klasses: Optional[List[Callable[[str], str]]] = None, hole_diameter: float = 14.0, hole_depth: float = 10.0, max_sheets_per_hole: int = 100, @@ -112,12 +133,17 @@ def magazine_factory( size_y: 宽度 (mm) size_z: 高度 (mm) locations: 洞位坐标列表 + klasses: 每个洞位中极片的类列表 hole_diameter: 洞直径 (mm) hole_depth: 洞深度 (mm) max_sheets_per_hole: 每个洞位最大极片数量 category: 类别 model: 型号 """ + for loc in locations: + loc.x -= hole_diameter / 2 + loc.y -= hole_diameter / 2 + # 创建洞位 _sites = create_homogeneous_resources( klass=Magazine, @@ -132,7 +158,7 @@ def magazine_factory( keys = [f"A{i+1}" for i in range(len(locations))] sites = dict(zip(keys, _sites.values())) - return MagazineHolder( + holder = MagazineHolder( name=name, size_x=size_x, size_y=size_y, @@ -145,18 +171,143 @@ def magazine_factory( model=model, ) + if klasses is not None: + for i, klass in enumerate(klasses): + hole_key = keys[i] + hole = holder.children[i] + for j in reversed(range(max_sheets_per_hole)): + item_name = f"{hole_key}_sheet{j+1}" + item = klass(name=item_name) + hole.assign_child_resource(item) + return holder -def MagazineHolder_4( + +def MagazineHolder_6_Cathode( name: str, size_x: float = 80.0, size_y: float = 80.0, - size_z: float = 10.0, + size_z: float = 40.0, hole_diameter: float = 14.0, hole_depth: float = 10.0, - hole_spacing: float = 25.0, + hole_spacing: float = 20.0, max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建6孔子弹夹 - 六边形排布""" + center_x = size_x / 2 + center_y = size_y / 2 + + locations = [] + + # 周围6个孔,按六边形排布 + for i in range(6): + angle = i * 60 * math.pi / 180 # 每60度一个孔 + x = center_x + hole_spacing * math.cos(angle) + y = center_y + hole_spacing * math.sin(angle) + locations.append(Coordinate(x, y, size_z - hole_depth)) + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan], + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="magazine_holder", + model="MagazineHolder_6_Cathode", + ) + + +def MagazineHolder_6_Anode( + name: str, + size_x: float = 80.0, + size_y: float = 80.0, + size_z: float = 40.0, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + hole_spacing: float = 20.0, + max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建6孔子弹夹 - 六边形排布""" + center_x = size_x / 2 + center_y = size_y / 2 + + locations = [] + + # 周围6个孔,按六边形排布 + for i in range(6): + angle = i * 60 * math.pi / 180 # 每60度一个孔 + x = center_x + hole_spacing * math.cos(angle) + y = center_y + hole_spacing * math.sin(angle) + locations.append(Coordinate(x, y, size_z - hole_depth)) + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan], + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="magazine_holder", + model="MagazineHolder_6_Anode", + ) + + +def MagazineHolder_6_Battery( + name: str, + size_x: float = 80.0, + size_y: float = 80.0, + size_z: float = 40.0, + hole_diameter: float = 14.0, + hole_depth: float = 10.0, + hole_spacing: float = 20.0, + max_sheets_per_hole: int = 100, +) -> MagazineHolder: + """创建6孔子弹夹 - 六边形排布""" + center_x = size_x / 2 + center_y = size_y / 2 + + locations = [] + + # 周围6个孔,按六边形排布 + for i in range(6): + angle = i * 60 * math.pi / 180 # 每60度一个孔 + x = center_x + hole_spacing * math.cos(angle) + y = center_y + hole_spacing * math.sin(angle) + locations.append(Coordinate(x, y, size_z - hole_depth)) + + return magazine_factory( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + locations=locations, + klasses=None, # 初始化时,不放入装好的电池 + hole_diameter=hole_diameter, + hole_depth=hole_depth, + max_sheets_per_hole=max_sheets_per_hole, + category="magazine_holder", + model="MagazineHolder_6_Battery", + ) + + +def MagazineHolder_4_Cathode( + name: str, ) -> MagazineHolder: """创建4孔子弹夹 - 正方形四角排布""" + size_x: float = 80.0 + size_y: float = 80.0 + size_z: float = 10.0 + hole_diameter: float = 14.0 + hole_depth: float = 10.0 + hole_spacing: float = 25.0 + max_sheets_per_hole: int = 100 + # 计算4个洞位的坐标(正方形四角排布) center_x = size_x / 2 center_y = size_y / 2 @@ -175,110 +326,10 @@ def MagazineHolder_4( size_y=size_y, size_z=size_z, locations=locations, + klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode], hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, - category="clip_magazine_four", + category="magazine_holder", + model="MagazineHolder_4_Cathode", ) - - -def MagazineHolder_2( - name: str, - size_x: float = 80.0, - size_y: float = 80.0, - size_z: float = 10.0, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, -) -> MagazineHolder: - """创建2孔子弹夹 - 竖向排布""" - # 计算2个洞位的坐标(竖向排布) - center_x = size_x / 2 - center_y = size_y / 2 - offset = hole_spacing / 2 - - locations = [ - Coordinate(center_x, center_y - offset, size_z - hole_depth), # 下方 - Coordinate(center_x, center_y + offset, size_z - hole_depth), # 上方 - ] - - return magazine_factory( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category="clip_magazine_two", - ) - - -def MagazineHolder_1( - name: str, - size_x: float = 80.0, - size_y: float = 80.0, - size_z: float = 10.0, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - max_sheets_per_hole: int = 100, -) -> MagazineHolder: - """创建1孔子弹夹 - 中心单孔""" - # 计算1个洞位的坐标(中心位置) - center_x = size_x / 2 - center_y = size_y / 2 - - locations = [ - Coordinate(center_x, center_y, size_z - hole_depth), # 中心 - ] - - return magazine_factory( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category="clip_magazine_one", - ) - - -def MagazineHolder_6( - name: str, - size_x: float = 80.0, - size_y: float = 80.0, - size_z: float = 40.0, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 20.0, - max_sheets_per_hole: int = 100, -) -> MagazineHolder: - """创建6孔子弹夹 - 六边形排布""" - # 计算6个洞位的坐标(六边形排布:中心1个,周围5个) - center_x = size_x / 2 - center_y = size_y / 2 - - locations = [] - - # 周围6个孔,按六边形排布 - for i in range(6): - angle = i * 60 * math.pi / 180 # 每60度一个孔 - x = center_x + hole_spacing * math.cos(angle) - y = center_y + hole_spacing * math.sin(angle) - locations.append(Coordinate(x, y, size_z - hole_depth)) - - return magazine_factory( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category="clip_magazine_six", - ) \ No newline at end of file