Compare commits

...

6 Commits

Author SHA1 Message Date
Xuwznln
5c047beb83 support container as example
add z index

(cherry picked from commit 145fcaae65)
2026-03-03 18:04:13 +08:00
Xuwznln
b40c087143 fix container volume 2026-03-03 17:13:32 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
a54e7c0f23 new workflow & prcxi slot removal 2026-03-02 18:29:25 +08:00
Xuwznln
e5015cd5e0 fix size change 2026-03-02 15:52:44 +08:00
10 changed files with 606 additions and 830 deletions

View File

@@ -1,6 +1,7 @@
import argparse
import asyncio
import os
import platform
import shutil
import signal
import sys
@@ -358,7 +359,7 @@ def main():
if BasicConfig.test_mode:
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
BasicConfig.communication_protocol = "websocket"
machine_name = os.popen("hostname").read().strip()
machine_name = platform.node()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
BasicConfig.machine_name = machine_name
BasicConfig.vis_2d_enable = args_dict["2d_vis"]

View File

@@ -55,6 +55,7 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
TransferLiquidReturn,
)
from unilabos.registry.placeholder_type import ResourceSlot
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -90,20 +91,103 @@ class PRCXI9300Deck(Deck):
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
# T1-T16 默认位置 (4列×4行)
_DEFAULT_SITE_POSITIONS = [
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__(size_x, size_y, size_z, name)
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
self.slot_locations = [Coordinate(0, 0, 0)] * 16
if sites is not None:
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
else:
self.sites = []
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}",
"visible": True,
"position": {"x": x, "y": y, "z": z},
"size": dict(self._DEFAULT_SITE_SIZE),
"content_type": list(self._DEFAULT_CONTENT_TYPE),
})
# _ordering: label -> None, 用于外部通过 list(keys()).index(site) 将 Tn 转换为 spot index
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
def _get_site_location(self, idx: int) -> Coordinate:
pos = self.sites[idx]["position"]
return Coordinate(pos["x"], pos["y"], pos["z"])
def _get_site_resource(self, idx: int) -> Optional[Resource]:
site_loc = self._get_site_location(idx)
for child in self.children:
if child.location == site_loc:
return child
return None
def assign_child_resource(
self,
resource: Resource,
location: Optional[Coordinate] = None,
reassign: bool = True,
spot: Optional[int] = None,
):
idx = spot
if spot is not None:
idx = spot
else:
for i, site in enumerate(self.sites):
site_loc = self._get_site_location(i)
if site.get("label") == resource.name:
idx = i
break
if location is not None and site_loc == location:
idx = i
break
if idx is None:
for i in range(len(self.sites)):
if self._get_site_resource(i) is None:
idx = i
break
if idx is None:
raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'")
if not reassign and self._get_site_resource(idx) is not None:
raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied")
loc = self._get_site_location(idx)
super().assign_child_resource(resource, location=loc, reassign=reassign)
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
if self.slots[slot - 1] is not None and not reassign:
raise ValueError(f"Spot {slot} is already occupied")
self.assign_child_resource(resource, spot=slot - 1, reassign=reassign)
self.slots[slot - 1] = resource
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
def serialize(self) -> dict:
data = super().serialize()
sites_out = []
for i, site in enumerate(self.sites):
occupied = self._get_site_resource(i)
sites_out.append({
"label": site["label"],
"visible": site.get("visible", True),
"occupied_by": occupied.name if occupied is not None else None,
"position": site["position"],
"size": site["size"],
"content_type": site["content_type"],
})
data["sites"] = sites_out
return data
class PRCXI9300Container(Plate):
class PRCXI9300Container(Container):
"""PRCXI 9300 的专用 Container 类,继承自 Plate用于槽位定位和未知模块。
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
@@ -116,11 +200,10 @@ class PRCXI9300Container(Plate):
size_y: float,
size_z: float,
category: str,
ordering: collections.OrderedDict,
model: Optional[str] = None,
**kwargs,
):
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
super().__init__(name, size_x, size_y, size_z, category=category, model=model)
self._unilabos_state = {}
def load_state(self, state: Dict[str, Any]) -> None:
@@ -567,14 +650,14 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
tablets_info = []
count = 0
for child in deck.children:
if child.children:
if "Material" in child.children[0]._unilabos_state:
number = int(child.name.replace("T", ""))
tablets_info.append(
WorkTablets(
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
)
# 如果放其他类型的物料,是不可以的
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
number = int(child.name.replace("T", ""))
tablets_info.append(
WorkTablets(
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
)
)
if is_9320:
print("当前设备是9320")
# 始终初始化 step_mode 属性

View File

@@ -175,7 +175,8 @@ class Registry:
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
"class_name": "unilabos_class",
"class_name": "unilabos_class", # 当前实验室物料的class name
"slot_on_deck": "unilabos_resource_slot:parent", # 勾选的parent的config中的sites的name展示name参数对应slotindex
},
},
"test_latency": {

View File

@@ -1,10 +1,6 @@
import json
from typing import Dict, Any
from pylabrobot.resources import Container
from unilabos_msgs.msg import Resource
from unilabos.ros.msgs.message_converter import convert_from_ros_msg
class RegularContainer(Container):
@@ -16,12 +12,12 @@ class RegularContainer(Container):
kwargs["size_y"] = 0
if "size_z" not in kwargs:
kwargs["size_z"] = 0
self.kwargs = kwargs
self.state = {}
super().__init__(*args, category="container", **kwargs)
def load_state(self, state: Dict[str, Any]):
self.state = state
super().load_state(state)
def get_regular_container(name="container"):
@@ -29,7 +25,6 @@ def get_regular_container(name="container"):
r.category = "container"
return r
#
# class RegularContainer(object):
# # 第一个参数必须是id传入
# # noinspection PyShadowingBuiltins
@@ -89,4 +84,4 @@ def get_regular_container(name="container"):
# return to_dict
#
# def __str__(self):
# return f"{self.id}"
# return f"{self.id}"

View File

@@ -76,7 +76,7 @@ def canonicalize_nodes_data(
if sample_id:
logger.error(f"{node}的sample_id参数已弃用sample_id: {sample_id}")
for k in list(node.keys()):
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]:
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose", "extra"]:
v = node.pop(k)
node["config"][k] = v
if outer_host_node_id is not None:

View File

@@ -16,6 +16,7 @@ if TYPE_CHECKING:
EXTRA_CLASS = "unilabos_resource_class"
FRONTEND_POSE_EXTRA = "unilabos_frontend_pose_extra"
EXTRA_SAMPLE_UUID = "sample_uuid"
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
@@ -74,6 +75,14 @@ class ResourceDictPositionObject(BaseModel):
z: float = Field(description="Z coordinate", default=0.0)
class ResourceDictPoseExtraObjectType(BaseModel):
z_index: int
class ResourceDictPoseExtraObject(BaseModel):
z_index: Optional[int] = Field(alias="zIndex", default=None)
class ResourceDictPositionType(TypedDict):
size: ResourceDictPositionSizeType
scale: ResourceDictPositionScaleType
@@ -100,6 +109,7 @@ class ResourceDictPosition(BaseModel):
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] = Field(
description="Cross section type", default="rectangle"
)
extra: Optional[ResourceDictPoseExtraObject] = Field(description="Extra data", default=None)
class ResourceDictType(TypedDict):
@@ -411,6 +421,15 @@ class ResourceTreeSet(object):
"tip_spot": "tip_spot",
"tube": "tube",
"bottle_carrier": "bottle_carrier",
"material_hole": "material_hole",
"container": "container",
"material_plate": "material_plate",
"electrode_sheet": "electrode_sheet",
"warehouse": "warehouse",
"magazine_holder": "magazine_holder",
"resource_group": "resource_group",
"trash": "trash",
"plate_adapter": "plate_adapter",
}
if source in replace_info:
return replace_info[source]
@@ -454,6 +473,7 @@ class ResourceTreeSet(object):
"position3d": raw_pos,
"rotation": d["rotation"],
"cross_section_type": d.get("cross_section_type", "rectangle"),
"extra": extra.get(FRONTEND_POSE_EXTRA)
}
# 先构建当前节点的字典不包含children
@@ -539,6 +559,7 @@ class ResourceTreeSet(object):
name_to_uuid[node.res_content.name] = node.res_content.uuid
all_states[node.res_content.name] = node.res_content.data
name_to_extra[node.res_content.name] = node.res_content.extra
name_to_extra[node.res_content.name][FRONTEND_POSE_EXTRA] = node.res_content.pose.extra
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
for child in node.children:
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
@@ -607,7 +628,7 @@ class ResourceTreeSet(object):
plr_resources.append(plr_resource)
except Exception as e:
logger.error(f"转换 PLR 资源失败: {e}")
logger.error(f"转换 PLR 资源失败: {e} {str(plr_dict)[:1000]}")
import traceback
logger.error(f"堆栈: {traceback.format_exc()}")

View File

@@ -915,8 +915,24 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else []
)
if target_site is not None and sites is not None and site_names is not None:
site_index = sites.index(original_instance)
site_name = site_names[site_index]
site_index = None
try:
# sites 可能是 Resource 列表或 dict 列表 (如 PRCXI9300Deck)
# 只有itemized_carrier在使用准备弃用
site_index = sites.index(original_instance)
except ValueError:
# dict 类型的 sites: 通过name匹配
for idx, site in enumerate(sites):
if original_instance.name == site["occupied_by"]:
site_index = idx
break
elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]):
site_index = idx
break
if site_index is None:
site_name = None
else:
site_name = site_names[site_index]
if site_name != target_site:
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
if parent is not None:
@@ -924,6 +940,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
parent_appended = True
# 加载状态
# noinspection PyProtectedMember
original_instance._size_x = plr_resource._size_x
# noinspection PyProtectedMember
original_instance._size_y = plr_resource._size_y
# noinspection PyProtectedMember
original_instance._size_z = plr_resource._size_z
# noinspection PyProtectedMember
original_instance._local_size_z = plr_resource._local_size_z
original_instance.location = plr_resource.location
original_instance.rotation = plr_resource.rotation
original_instance.barcode = plr_resource.barcode
@@ -984,7 +1008,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
].call_async(
r
) # type: ignore
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}")
results.append(result)
elif action == "update":
if tree_set is None:
@@ -1010,7 +1034,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
].call_async(
r
) # type: ignore
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}")
results.append(result)
elif action == "remove":
result = _handle_remove(resources_uuid)

View File

@@ -1195,7 +1195,7 @@ class HostNode(BaseROS2DeviceNode):
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
response.response = json.dumps(uuid_mapping)
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
"""

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@
res_id: plate_slot_{slot}
device_id: /PRCXI
class_name: PRCXI_BioER_96_wellplate
parent: /PRCXI/PRCXI_Deck/T{slot}
parent: /PRCXI/PRCXI_Deck
slot_on_deck: "{slot}"
- 输出端口: labware用于连接 set_liquid_from_plate
- 控制流: create_resource 之间通过 ready 端口串联
@@ -122,7 +122,7 @@ NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
# create_resource 节点默认参数
CREATE_RESOURCE_DEFAULTS = {
"device_id": "/PRCXI",
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
"parent_template": "/PRCXI/PRCXI_Deck",
"class_name": "PRCXI_BioER_96_wellplate",
}
@@ -424,7 +424,7 @@ def build_protocol_graph(
"res_id": res_id,
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
"class_name": lw_type,
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"],
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
"slot_on_deck": slot,
},