Merge branch 'pr/169' into workstation_YB_merge_dev_ready_260113

This commit is contained in:
ZiWei
2026-01-15 14:26:42 +08:00
26 changed files with 3283 additions and 1072 deletions

View File

@@ -193,3 +193,20 @@ def BIOYOND_PolymerStation_Flask(
barcode=barcode,
model="BIOYOND_PolymerStation_Flask",
)
def BIOYOND_PolymerStation_Measurement_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 20000.0, # 20mL
barcode: str = None,
) -> Bottle:
"""创建测量小瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Measurement_Vial",
)

View File

@@ -51,20 +51,17 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01B03
}
self.warehouse_locations = {
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
"堆栈1左": Coordinate(-200.0, 450.0, 0.0), # 左侧位置
"堆栈1右": Coordinate(2350.0, 450.0, 0.0), # 右侧位置
"站内试剂存放堆栈": Coordinate(730.0, 390.0, 0.0),
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
"测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
"测量小瓶仓库(测密度)": Coordinate(940.0, 530.0, 0.0),
}
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_PolymerPreparationStation_Deck(Deck):
def __init__(
self,
@@ -148,6 +145,7 @@ class BIOYOND_YB_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name)
by.setup()

View File

@@ -46,41 +46,55 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
)
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
"""创建测量小瓶仓库(测密度) A01B03"""
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行
布局(从下到上,从左到右):
| A03 | B03 | ← 顶部
| A02 | B02 | ← 中部
| A01 | B01 | ← 底部
"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列(01-03
num_items_y=2, # 2行(A-B
num_items_x=2, # 2列(A, B
num_items_y=3, # 3行(01-03从下到上
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=40.0,
item_dy=40.0,
item_dx=40.0, # 列间距A到B的横向距离
item_dy=40.0, # 行间距01到02到03的竖向距离
item_dz=50.0,
# 用更小的 resource_size 来表现 "小点的孔位"
# ⭐ 竖向warehouse槽位尺寸也是竖向的小瓶已经是正方形无需调整
resource_size_x=30.0,
resource_size_y=30.0,
resource_size_z=12.0,
category="warehouse",
col_offset=0,
layout="row-major",
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
)
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
"""创建BioYond站内试剂存放堆栈A01A02, 1行×2列"""
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行
布局(竖向,从下到上):
| A02 | ← 顶部
| A01 | ← 底部
"""
return warehouse_factory(
name=name,
num_items_x=2, # 2列01-02
num_items_y=1, # 1行(A
num_items_x=1, # 1列
num_items_y=2, # 2行(01-02从下到上
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dx=96.0, # 列间距这里只有1列不重要
item_dy=137.0, # 行间距A01到A02的竖向距离
item_dz=120.0,
# ⭐ 竖向warehouse交换槽位尺寸使槽位框也是竖向的
resource_size_x=86.0, # 原来的 resource_size_y
resource_size_y=127.0, # 原来的 resource_size_x
resource_size_z=25.0,
category="warehouse",
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
)
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:

View File

@@ -27,7 +27,7 @@ class RegularContainer(Container):
def get_regular_container(name="container"):
r = RegularContainer(name=name)
r.category = "container"
return RegularContainer(name=name)
return r
#
# class RegularContainer(object):

View File

@@ -779,6 +779,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if not locations:
logger.debug(f"[物料位置] {unique_name} 没有location信息跳过warehouse放置")
# ⭐ 预先检查如果物料的任何location在竖向warehouse中提前交换尺寸
# 这样可以避免多个location时尺寸不一致的问题
needs_size_swap = False
for loc in locations:
wh_name_check = loc.get("whName")
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
needs_size_swap = True
break
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
original_x = plr_material.size_x
original_y = plr_material.size_y
plr_material.size_x = original_y
plr_material.size_y = original_x
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse预先交换尺寸: {original_x}×{original_y}{plr_material.size_x}×{plr_material.size_y}")
for loc in locations:
wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
@@ -800,7 +816,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
@@ -809,12 +824,23 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4
# 特殊处理对于1行×N列的横向warehouse站内试剂存放堆栈)
# Bioyond的y坐标表示线性位置序号而不是列号
if warehouse.num_items_y == 1:
# 1行warehouse: 直接用y作为线性索引
idx = y - 1
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
# 特殊处理向warehouse站内试剂存放堆栈、测量小瓶仓库
# 这些warehouse使用 vertical-col-major 布局
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
# vertical-col-major 布局的坐标映射:
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向)
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上
# vertical-col-major 中: row=0 对应底部row=n-1 对应顶部
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
# 索引计算: idx = row * num_cols + col
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
layer_idx = z - 1
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
# 普通横向warehouse的处理
else:
# 多行warehouse: 根据 layout 使用不同的索引计算
row_idx = x - 1 # x表示行: 转为0-based
@@ -838,6 +864,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
# 物料尺寸已在放入warehouse前根据需要进行了交换
warehouse[idx] = plr_material
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
else:
@@ -1011,11 +1038,24 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
# 🎯 处理物料默认参数和单位
# 检查是否有该物料名称的默认参数配置
# 优先级: typeId参数 > 物料名称参数 > 默认值
default_unit = "" # 默认单位
material_parameters = {}
if material_name in material_params:
# 1⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取key 格式为 "type:<typeId>"
type_params_key = f"type:{type_id}"
if type_params_key in material_params:
params_config = material_params[type_params_key].copy()
# 提取 unit 字段(如果有)
if "unit" in params_config:
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
# 剩余的字段放入 Parameters
material_parameters = params_config
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
# 2⃣ 其次检查是否有该物料名称的默认参数配置
elif material_name in material_params:
params_config = material_params[material_name].copy()
# 提取 unit 字段(如果有)
@@ -1024,7 +1064,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
# 剩余的字段放入 Parameters
material_parameters = params_config
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
# 转换为 JSON 字符串
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
@@ -1151,11 +1191,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
if resource_class_config["type"] == "pylabrobot":
resource_plr = RESOURCE(name=resource_config["name"])
if resource_type != ResourcePLR:
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr])
# r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None))
# # r = resource_plr_to_ulab(resource_plr=resource_plr)
# if resource_config.get("position") is not None:
# r["position"] = resource_config["position"]
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True)
r = tree_sets.dump()
else:
r = resource_plr

View File

@@ -50,12 +50,45 @@ class Bottle(Well):
self.barcode = barcode
def serialize(self) -> dict:
# Pylabrobot expects barcode to be an object with serialize(), but here it is a str.
# We temporarily unset it to avoid AttributeError in super().serialize().
_barcode = self.barcode
self.barcode = None
try:
data = super().serialize()
finally:
self.barcode = _barcode
return {
**super().serialize(),
**data,
"diameter": self.diameter,
"height": self.height,
}
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
# Extract barcode before calling parent deserialize to avoid type error
barcode_data = data.pop("barcode", None)
# Call parent deserialize
instance = super(Bottle, cls).deserialize(data, allow_marshal=allow_marshal)
# Set barcode as string (not as Barcode object)
if barcode_data:
if isinstance(barcode_data, str):
instance.barcode = barcode_data
elif isinstance(barcode_data, dict):
# If it's a dict (Barcode serialized format), extract the data field
instance.barcode = barcode_data.get("data", "")
else:
instance.barcode = ""
# Set additional attributes
instance.diameter = data.get("diameter", instance._size_x)
instance.height = data.get("height", instance._size_z)
return instance
T = TypeVar("T", bound=ResourceHolder)
S = TypeVar("S", bound=ResourceHolder)

View File

@@ -1,7 +1,7 @@
import inspect
import traceback
import uuid
from pydantic import BaseModel, field_serializer, field_validator
from pydantic import BaseModel, field_serializer, field_validator, ValidationError
from pydantic import Field
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
@@ -147,20 +147,24 @@ class ResourceDictInstance(object):
if not content.get("extra"): # MagicCode
content["extra"] = {}
if "position" in content:
pose = content.get("pose",{})
if "position" not in pose :
pose = content.get("pose", {})
if "position" not in pose:
if "position" in content["position"]:
pose["position"] = content["position"]["position"]
else:
pose["position"] = {"x": 0, "y": 0, "z": 0}
if "size" not in pose:
pose["size"] = {
"width": content["config"].get("size_x", 0),
"height": content["config"].get("size_y", 0),
"depth": content["config"].get("size_z", 0)
"width": content["config"].get("size_x", 0),
"height": content["config"].get("size_y", 0),
"depth": content["config"].get("size_z", 0),
}
content["pose"] = pose
return ResourceDictInstance(ResourceDict.model_validate(content))
try:
res_dict = ResourceDict.model_validate(content)
return ResourceDictInstance(res_dict)
except ValidationError as err:
raise err
def get_plr_nested_dict(self) -> Dict[str, Any]:
"""获取资源实例的嵌套字典表示"""
@@ -322,7 +326,7 @@ class ResourceTreeSet(object):
)
@classmethod
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet":
def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
"""
从plr资源创建ResourceTreeSet
"""
@@ -339,6 +343,8 @@ class ResourceTreeSet(object):
}
if source in replace_info:
return replace_info[source]
elif source is None:
return ""
else:
print("转换pylabrobot的时候出现未知类型", source)
return source
@@ -349,7 +355,8 @@ class ResourceTreeSet(object):
if not uid:
uid = str(uuid.uuid4())
res.unilabos_uuid = uid
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}")
if not known_newly_created:
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}")
# 获取unilabos_extra默认为空字典
extra = getattr(res, "unilabos_extra", {})
@@ -448,7 +455,13 @@ class ResourceTreeSet(object):
from pylabrobot.utils.object_parsing import find_subclass
# 类型映射
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"}
TYPE_MAP = {
"plate": "Plate",
"well": "Well",
"deck": "Deck",
"container": "RegularContainer",
"tip_spot": "TipSpot",
}
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
@@ -608,6 +621,16 @@ class ResourceTreeSet(object):
"""
return [tree.root_node for tree in self.trees]
@property
def root_nodes_uuid(self) -> List[ResourceDictInstance]:
"""
获取所有树的根节点
Returns:
所有根节点的资源实例列表
"""
return [tree.root_node.res_content.uuid for tree in self.trees]
@property
def all_nodes(self) -> List[ResourceDictInstance]:
"""
@@ -918,6 +941,33 @@ class DeviceNodeResourceTracker(object):
return self._traverse_and_process(resource, process)
def loop_find_with_uuid(self, resource, target_uuid: str):
"""
递归遍历资源树,根据 uuid 查找并返回对应的资源
Args:
resource: 资源对象可以是list、dict或实例
target_uuid: 要查找的uuid
Returns:
找到的资源对象未找到则返回None
"""
found_resource = None
def process(res):
nonlocal found_resource
if found_resource is not None:
return 0 # 已找到,跳过后续处理
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
if current_uuid and current_uuid == target_uuid:
found_resource = res
logger.trace(f"找到资源UUID: {target_uuid}")
return 1
return 0
self._traverse_and_process(resource, process)
return found_resource
def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int:
"""
递归遍历资源树,根据 name 设置所有节点的 extra
@@ -1103,7 +1153,7 @@ class DeviceNodeResourceTracker(object):
for key in keys_to_remove:
self.resource2parent_resource.pop(key, None)
logger.debug(f"成功移除资源: {resource}")
logger.trace(f"[ResourceTracker] 成功移除资源: {resource}")
return True
def clear_resource(self):

View File

@@ -43,6 +43,10 @@ def warehouse_factory(
if layout == "row-major":
# 行优先row=0(A行) 应该显示在上方,需要较小的 y 值
y = dy + row * item_dy
elif layout == "vertical-col-major":
# 竖向warehouse: row=0 对应顶部y小row=n-1 对应底部y大
# 但标签 01 应该在底部,所以使用反向映射
y = dy + (num_items_y - row - 1) * item_dy
else:
# 列优先保持原逻辑row=0 对应较大的 y
y = dy + (num_items_y - row - 1) * item_dy