create warehouse by factory func

This commit is contained in:
Junhan Chang
2025-09-30 11:57:34 +08:00
parent 9bbae96447
commit 7b426ed5ae
6 changed files with 169 additions and 72 deletions

View File

@@ -4,15 +4,20 @@ Bioyond Workstation Implementation
集成Bioyond物料管理的工作站示例 集成Bioyond物料管理的工作站示例
""" """
import traceback
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
import json import json
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
from unilabos.resources.warehouse import WareHouse
from unilabos.utils.log import logger from unilabos.utils.log import logger
from unilabos.resources.graphio import resource_bioyond_to_plr from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG, WORKFLOW_MAPPINGS from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS
class BioyondResourceSynchronizer(ResourceSynchronizer): class BioyondResourceSynchronizer(ResourceSynchronizer):
@@ -26,6 +31,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
self.bioyond_api_client = None self.bioyond_api_client = None
self.sync_interval = 60 # 默认60秒同步一次 self.sync_interval = 60 # 默认60秒同步一次
self.last_sync_time = 0 self.last_sync_time = 0
self.initialize()
def initialize(self) -> bool: def initialize(self) -> bool:
"""初始化Bioyond资源同步器""" """初始化Bioyond资源同步器"""
@@ -36,7 +42,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
return False return False
# 设置同步间隔 # 设置同步间隔
self.sync_interval = self.workstation.bioyond_config.get("sync_interval", 60) self.sync_interval = self.workstation.bioyond_config.get("sync_interval", 600)
logger.info("Bioyond资源同步器初始化完成") logger.info("Bioyond资源同步器初始化完成")
return True return True
@@ -51,18 +57,19 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.error("Bioyond API客户端未初始化") logger.error("Bioyond API客户端未初始化")
return False return False
bioyond_data = self.bioyond_api_client.fetch_materials() bioyond_data = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}')
if not bioyond_data: if not bioyond_data:
logger.warning("从Bioyond获取的物料数据为空") logger.warning("从Bioyond获取的物料数据为空")
return False return False
# 转换为UniLab格式 # 转换为UniLab格式
unilab_resources = resource_bioyond_to_plr(bioyond_data, deck=self.workstation.deck) unilab_resources = resource_bioyond_to_plr(bioyond_data, type_mapping=self.workstation.bioyond_config["material_type_mappings"], deck=self.workstation.deck)
logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源") logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源")
return True return True
except Exception as e: except Exception as e:
logger.error(f"从Bioyond同步物料数据失败: {e}") logger.error(f"从Bioyond同步物料数据失败: {e}")
traceback.print_exc()
return False return False
def sync_to_external(self, resource: Any) -> bool: def sync_to_external(self, resource: Any) -> bool:
@@ -105,8 +112,6 @@ class BioyondWorkstation(WorkstationBase):
*args, *args,
**kwargs, **kwargs,
): ):
self._create_communication_module(bioyond_config)
# 初始化父类 # 初始化父类
super().__init__( super().__init__(
# 桌子 # 桌子
@@ -114,19 +119,34 @@ class BioyondWorkstation(WorkstationBase):
*args, *args,
**kwargs, **kwargs,
) )
self.deck.warehouses = {}
for resource in self.deck.children:
if isinstance(resource, WareHouse):
self.deck.warehouses[resource.name] = resource
self._create_communication_module(bioyond_config)
self.resource_synchronizer = BioyondResourceSynchronizer(self) self.resource_synchronizer = BioyondResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external() self.resource_synchronizer.sync_from_external()
# TODO: self._ros_node里面拿属性 # TODO: self._ros_node里面拿属性
logger.info(f"Bioyond工作站初始化完成") logger.info(f"Bioyond工作站初始化完成")
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
#self.deck = create_a_coin_cell_deck()
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None: def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
"""创建Bioyond通信模块""" """创建Bioyond通信模块"""
self.bioyond_config = config or { self.bioyond_config = config or {
**API_CONFIG, **API_CONFIG,
"workflow_mappings": WORKFLOW_MAPPINGS "workflow_mappings": WORKFLOW_MAPPINGS,
"material_type_mappings": MATERIAL_TYPE_MAPPINGS
} }
self.hardware_interface = BioyondV1RPC(self.bioyond_config) self.hardware_interface = BioyondV1RPC(self.bioyond_config)
return None return None
def _register_supported_workflows(self): def _register_supported_workflows(self):

View File

@@ -0,0 +1,12 @@
BIOYOND_PolymerReactionStation_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck
type: pylabrobot
description: BIOYOND PolymerReactionStation Deck
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -1,11 +1,20 @@
from pylabrobot.resources import Deck, Coordinate from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.resources.bioyond.warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling from unilabos.resources.bioyond.warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling
class BIOYOND_PolymerReactionStation_Deck(Deck): class BIOYOND_PolymerReactionStation_Deck(Deck):
def __init__(self, name: str = "PolymerReactionStation_Deck") -> None: def __init__(
self,
name: str = "PolymerReactionStation_Deck",
size_x: float = 2700.0,
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup() self.setup()
def setup(self) -> None: def setup(self) -> None:
@@ -20,7 +29,7 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
"堆栈2": Coordinate(2550.0, 650.0, 0.0), "堆栈2": Coordinate(2550.0, 650.0, 0.0),
"站内试剂存放堆栈": Coordinate(800.0, 475.0, 0.0), "站内试剂存放堆栈": Coordinate(800.0, 475.0, 0.0),
} }
self.warehouses["站内试剂存放堆栈"].rotation = 90.0 self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])

View File

@@ -1,9 +1,9 @@
from unilabos.resources.warehouse import WareHouse from unilabos.resources.warehouse import WareHouse, warehouse_factory
def bioyond_warehouse_1x4x4(name: str) -> WareHouse: def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
"""创建BioYond 4x1x4仓库""" """创建BioYond 4x1x4仓库"""
return WareHouse( return warehouse_factory(
name=name, name=name,
num_items_x=1, num_items_x=1,
num_items_y=4, num_items_y=4,
@@ -20,7 +20,7 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
def bioyond_warehouse_1x4x2(name: str) -> WareHouse: def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
"""创建BioYond 4x1x2仓库""" """创建BioYond 4x1x2仓库"""
return WareHouse( return warehouse_factory(
name=name, name=name,
num_items_x=1, num_items_x=1,
num_items_y=4, num_items_y=4,
@@ -38,7 +38,7 @@ def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse: def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse:
"""创建BioYond开关盖加液模块台面""" """创建BioYond开关盖加液模块台面"""
return WareHouse( return warehouse_factory(
name=name, name=name,
num_items_x=2, num_items_x=2,
num_items_y=5, num_items_y=5,

View File

@@ -71,6 +71,9 @@ class ItemizedCarrier(ResourcePLR):
size_x: float, size_x: float,
size_y: float, size_y: float,
size_z: float, size_z: float,
num_items_x: int = 0,
num_items_y: int = 0,
num_items_z: int = 0,
sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None, sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None,
category: Optional[str] = "carrier", category: Optional[str] = "carrier",
model: Optional[str] = None, model: Optional[str] = None,
@@ -83,10 +86,12 @@ class ItemizedCarrier(ResourcePLR):
category=category, category=category,
model=model, model=model,
) )
self.num_items = len(sites)
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
if isinstance(sites, dict):
sites = sites or {} sites = sites or {}
self.sites: List[Optional[ResourcePLR]] = list(sites.values()) self.sites: List[Optional[ResourcePLR]] = list(sites.values())
self._ordering = sites self._ordering = sites
self.num_items = len(self.sites)
self.child_locations: Dict[str, Coordinate] = {} self.child_locations: Dict[str, Coordinate] = {}
for spot, resource in sites.items(): for spot, resource in sites.items():
if resource is not None and getattr(resource, "location", None) is None: if resource is not None and getattr(resource, "location", None) is None:
@@ -95,6 +100,13 @@ class ItemizedCarrier(ResourcePLR):
self.child_locations[spot] = resource.location self.child_locations[spot] = resource.location
else: else:
self.child_locations[spot] = Coordinate.zero() self.child_locations[spot] = Coordinate.zero()
elif isinstance(sites, list):
# deserialize时走这里还需要根据 self.sites 索引children
self.child_locations = {site["label"]: Coordinate(**site["position"]) for site in sites}
self.sites = [site["occupied_by"] for site in sites]
self._ordering = {site["label"]: site["position"] for site in sites}
else:
print("sites:", sites)
@property @property
def capacity(self): def capacity(self):
@@ -112,7 +124,20 @@ class ItemizedCarrier(ResourcePLR):
reassign: bool = True, reassign: bool = True,
spot: Optional[int] = None, spot: Optional[int] = None,
): ):
idx = spot if spot is not None else len(self.sites) idx = spot
# 如果只给 location根据坐标和 deserialize 后的 self.sites持有names来寻找 resource 该摆放的位置
if spot is not None:
idx = spot
else:
for i, site in enumerate(self.sites):
site_location = list(self.child_locations.values())[i]
if type(site) == str and site == resource.name:
idx = i
break
if site_location == location:
idx = i
break
if not reassign and self.sites[idx] is not None: if not reassign and self.sites[idx] is not None:
raise ValueError(f"a site with index {idx} already exists") raise ValueError(f"a site with index {idx} already exists")
super().assign_child_resource(resource, location=location, reassign=reassign) super().assign_child_resource(resource, location=location, reassign=reassign)
@@ -288,9 +313,15 @@ class ItemizedCarrier(ResourcePLR):
def serialize(self): def serialize(self):
return { return {
**super().serialize(), **super().serialize(),
"slots": [{ "num_items_x": self.num_items_x,
"num_items_y": self.num_items_y,
"num_items_z": self.num_items_z,
"sites": [{
"label": str(identifier), "label": str(identifier),
"visible": True if self[identifier] is not None else False, "visible": True if self[identifier] is not None else False,
"occupied_by": self[identifier].name
if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else
self[identifier] if isinstance(self[identifier], str) else None,
"position": {"x": location.x, "y": location.y, "z": location.z}, "position": {"x": location.x, "y": location.y, "z": location.z},
"size": {"width": self._size_x, "height": self._size_y, "depth": self._size_z}, "size": {"width": self._size_x, "height": self._size_y, "depth": self._size_z},
"content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"] "content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"]

View File

@@ -1,15 +1,11 @@
from typing import Optional, List from typing import Dict, Optional, List, Union
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources
from unilabos.resources.itemized_carrier import ItemizedCarrier from unilabos.resources.itemized_carrier import ItemizedCarrier, ResourcePLR
class WareHouse(ItemizedCarrier): def warehouse_factory(
"""4x4x1堆栈载体类 - 可容纳16个板位的载体4层x4行x1列"""
def __init__(
self,
name: str, name: str,
num_items_x: int = 4, num_items_x: int = 4,
num_items_y: int = 1, num_items_y: int = 1,
@@ -24,13 +20,9 @@ class WareHouse(ItemizedCarrier):
empty: bool = False, empty: bool = False,
category: str = "warehouse", category: str = "warehouse",
model: Optional[str] = None, model: Optional[str] = None,
): ):
self.num_items_x = num_items_x
self.num_items_y = num_items_y
self.num_items_z = num_items_z
# 创建16个板架位 (4层 x 4位置) # 创建16个板架位 (4层 x 4位置)
locations = [] locations = []
for layer in range(num_items_z): # 4层 for layer in range(num_items_z): # 4层
for row in range(num_items_y): # 4行 for row in range(num_items_y): # 4行
for col in range(num_items_x): # 1列 (每层4x1=4个位置) for col in range(num_items_x): # 1列 (每层4x1=4个位置)
@@ -38,11 +30,9 @@ class WareHouse(ItemizedCarrier):
x = dx + col * item_dx x = dx + col * item_dx
y = dy + (num_items_y - row - 1) * item_dy y = dy + (num_items_y - row - 1) * item_dy
z = dz + (num_items_z - layer - 1) * item_dz z = dz + (num_items_z - layer - 1) * item_dz
locations.append(Coordinate(x, y, z)) locations.append(Coordinate(x, y, z))
if removed_positions: if removed_positions:
locations = [loc for i, loc in enumerate(locations) if i not in removed_positions] locations = [loc for i, loc in enumerate(locations) if i not in removed_positions]
sites = create_homogeneous_resources( sites = create_homogeneous_resources(
klass=ResourceHolder, klass=ResourceHolder,
locations=locations, locations=locations,
@@ -51,11 +41,14 @@ class WareHouse(ItemizedCarrier):
name_prefix=name, name_prefix=name,
) )
super().__init__( return WareHouse(
name=name, name=name,
size_x=dx + item_dx * num_items_x, size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y, size_y=dy + item_dy * num_items_y,
size_z=dz + item_dz * num_items_z, size_z=dz + item_dz * num_items_z,
num_items_x = num_items_x,
num_items_y = num_items_y,
num_items_z = num_items_z,
# ordered_items=ordered_items, # ordered_items=ordered_items,
# ordering=ordering, # ordering=ordering,
sites=sites, sites=sites,
@@ -63,6 +56,38 @@ class WareHouse(ItemizedCarrier):
model=model, model=model,
) )
class WareHouse(ItemizedCarrier):
"""堆栈载体类 - 可容纳16个板位的载体4层x4行x1列"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
num_items_x: int,
num_items_y: int,
num_items_z: int,
sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None,
category: str = "warehouse",
model: Optional[str] = None,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
# ordered_items=ordered_items,
# ordering=ordering,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
sites=sites,
category=category,
model=model,
)
def get_site_by_layer_position(self, row: int, col: int, layer: int) -> ResourceHolder: def get_site_by_layer_position(self, row: int, col: int, layer: int) -> ResourceHolder:
if not (0 <= layer < 4 and 0 <= row < 4 and 0 <= col < 1): if not (0 <= layer < 4 and 0 <= row < 4 and 0 <= col < 1):
raise ValueError("无效的位置: layer={}, row={}, col={}".format(layer, row, col)) raise ValueError("无效的位置: layer={}, row={}, col={}".format(layer, row, col))