From cf3a20ae791b99329d82f3c270b243e1a8c206a2 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:45:51 +0800 Subject: [PATCH 1/3] registry update & workflow update --- unilabos/app/register.py | 8 ++-- .../device_comms/communication_devices.yaml | 3 ++ unilabos/registry/devices/camera.yaml | 3 ++ unilabos/registry/registry.py | 2 + unilabos/workflow/common.py | 43 ++++++++----------- unilabos/workflow/convert_from_json.py | 15 ++++--- 6 files changed, 41 insertions(+), 33 deletions(-) diff --git a/unilabos/app/register.py b/unilabos/app/register.py index 633df98f..5918b43a 100644 --- a/unilabos/app/register.py +++ b/unilabos/app/register.py @@ -38,9 +38,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[ response = http_client.resource_registry({"resources": list(devices_to_register.values())}) cost_time = time.time() - start_time if response.status_code in [200, 201]: - logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms") + logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s") else: - logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms") + logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s") except Exception as e: logger.error(f"[UniLab Register] 设备注册异常: {e}") @@ -51,9 +51,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[ response = http_client.resource_registry({"resources": list(resources_to_register.values())}) cost_time = time.time() - start_time if response.status_code in [200, 201]: - logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms") + logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s") else: - logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms") + logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s") except Exception as e: logger.error(f"[UniLab Register] 资源注册异常: {e}") diff --git a/unilabos/registry/device_comms/communication_devices.yaml b/unilabos/registry/device_comms/communication_devices.yaml index ea3f1b61..782889d4 100644 --- a/unilabos/registry/device_comms/communication_devices.yaml +++ b/unilabos/registry/device_comms/communication_devices.yaml @@ -96,10 +96,13 @@ serial: type: string port: type: string + registry_name: + type: string resource_tracker: type: object required: - device_id + - registry_name - port type: object data: diff --git a/unilabos/registry/devices/camera.yaml b/unilabos/registry/devices/camera.yaml index fe1aef28..c8b9d944 100644 --- a/unilabos/registry/devices/camera.yaml +++ b/unilabos/registry/devices/camera.yaml @@ -67,6 +67,9 @@ camera: period: default: 0.1 type: number + registry_name: + default: '' + type: string resource_tracker: type: object required: [] diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index df4758df..3d2c83e4 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -5,6 +5,7 @@ import sys import inspect import importlib import threading +import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Any, Dict, List, Union, Tuple @@ -944,6 +945,7 @@ class Registry: if is_valid: results.append((file, data, device_ids)) except Exception as e: + traceback.print_exc() logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}") # 线程安全地更新注册表 diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 381cc667..3a1fee22 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -362,14 +362,16 @@ def build_protocol_graph( protocol_steps: List[Dict[str, Any]], workstation_name: str, action_resource_mapping: Optional[Dict[str, str]] = None, + labware_defs: Optional[List[Dict[str, Any]]] = None, ) -> WorkflowGraph: """统一的协议图构建函数,根据设备类型自动选择构建逻辑 Args: - labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...} + labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找 protocol_steps: 协议步骤列表 workstation_name: 工作站名称 action_resource_mapping: action 到 resource_name 的映射字典,可选 + labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...] """ G = WorkflowGraph() resource_last_writer = {} # reagent_name -> "node_id:port" @@ -377,18 +379,7 @@ def build_protocol_graph( protocol_steps = refactor_data(protocol_steps, action_resource_mapping) - # ==================== 第一步:按 slot 去重创建 create_resource 节点 ==================== - # 收集所有唯一的 slot - slots_info = {} # slot -> {labware, res_id} - for labware_id, item in labware_info.items(): - slot = str(item.get("slot", "")) - if slot and slot not in slots_info: - res_id = f"plate_slot_{slot}" - slots_info[slot] = { - "labware": item.get("labware", ""), - "res_id": res_id, - } - + # ==================== 第一步:按 slot 创建 create_resource 节点 ==================== # 创建 Group 节点,包含所有 create_resource 节点 group_node_id = str(uuid.uuid4()) G.add_node( @@ -404,29 +395,35 @@ def build_protocol_graph( param=None, ) - # 为每个唯一的 slot 创建 create_resource 节点 + # 直接使用 JSON 中的 labware 定义,每个 slot 一条记录,type 即 class_name res_index = 0 - for slot, info in slots_info.items(): - node_id = str(uuid.uuid4()) - res_id = info["res_id"] + for lw in (labware_defs or []): + slot = str(lw.get("slot", "")) + if not slot or slot in slot_to_create_resource: + continue # 跳过空 slot 或已处理的 slot + + lw_name = lw.get("name", f"slot {slot}") + lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"]) + res_id = f"plate_slot_{slot}" res_index += 1 + node_id = str(uuid.uuid4()) G.add_node( node_id, template_name="create_resource", resource_name="host_node", - name=f"Plate {res_index}", - description=f"Create plate on slot {slot}", + name=lw_name, + description=f"Create {lw_name}", lab_node_type="Labware", footer="create_resource-host_node", device_name=DEVICE_NAME_HOST, type=NODE_TYPE_DEFAULT, - parent_uuid=group_node_id, # 指向 Group 节点 - minimized=True, # 折叠显示 + parent_uuid=group_node_id, + minimized=True, param={ "res_id": res_id, "device_id": CREATE_RESOURCE_DEFAULTS["device_id"], - "class_name": CREATE_RESOURCE_DEFAULTS["class_name"], + "class_name": lw_type, "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot), "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, "slot_on_deck": slot, @@ -434,8 +431,6 @@ def build_protocol_graph( ) slot_to_create_resource[slot] = node_id - # create_resource 之间不需要 ready 连接 - # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== # 创建 Group 节点,包含所有 set_liquid_from_plate 节点 set_liquid_group_id = str(uuid.uuid4()) diff --git a/unilabos/workflow/convert_from_json.py b/unilabos/workflow/convert_from_json.py index ff749d72..acd0d71a 100644 --- a/unilabos/workflow/convert_from_json.py +++ b/unilabos/workflow/convert_from_json.py @@ -1,16 +1,20 @@ """ JSON 工作流转换模块 -将 workflow/reagent 格式的 JSON 转换为统一工作流格式。 +将 workflow/reagent/labware 格式的 JSON 转换为统一工作流格式。 输入格式: { + "labware": [ + {"name": "...", "slot": "1", "type": "lab_xxx"}, + ... + ], "workflow": [ {"action": "...", "action_args": {...}}, ... ], "reagent": { - "reagent_name": {"slot": int, "well": [...], "labware": "..."}, + "reagent_name": {"slot": int, "well": [...]}, ... } } @@ -245,18 +249,18 @@ def convert_from_json( if "workflow" not in json_data or "reagent" not in json_data: raise ValueError( "不支持的 JSON 格式。请使用标准格式:\n" - '{"workflow": [{"action": "...", "action_args": {...}}, ...], ' - '"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}' + '{"labware": [...], "workflow": [...], "reagent": {...}}' ) # 提取数据 workflow = json_data["workflow"] reagent = json_data["reagent"] + labware_defs = json_data.get("labware", []) # 新的 labware 定义列表 # 规范化步骤数据 protocol_steps = normalize_workflow_steps(workflow) - # reagent 已经是字典格式,直接使用 + # reagent 已经是字典格式,用于 set_liquid 和 well 数量查找 labware_info = reagent # 构建工作流图 @@ -265,6 +269,7 @@ def convert_from_json( protocol_steps=protocol_steps, workstation_name=workstation_name, action_resource_mapping=ACTION_RESOURCE_MAPPING, + labware_defs=labware_defs, ) # 校验句柄配置 From 699a0b3ce77f9e7fae39d35416a5caa777445423 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:08:10 +0800 Subject: [PATCH 2/3] fix test resource schema --- unilabos/registry/registry.py | 35 +++++++------------------ unilabos/ros/nodes/presets/host_node.py | 2 +- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 3d2c83e4..1af0a4e3 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -89,6 +89,14 @@ class Registry: ) test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。" + test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {}) + test_resource_schema = self._generate_unilab_json_command_schema( + test_resource_method_info.get("args", []), + "auto-test_resource", + test_resource_method_info.get("return_annotation"), + ) + test_resource_schema["description"] = "用于测试物料、设备和样本。" + self.device_type_registry.update( { "host_node": { @@ -190,32 +198,7 @@ class Registry: "goal": {}, "feedback": {}, "result": {}, - "schema": { - "description": "", - "properties": { - "feedback": {}, - "goal": { - "properties": { - "resource": ros_message_to_json_schema(Resource, "resource"), - "resources": { - "items": { - "properties": ros_message_to_json_schema( - Resource, "resources" - ), - "type": "object", - }, - "type": "array", - }, - "device": {"type": "string"}, - "devices": {"items": {"type": "string"}, "type": "array"}, - }, - "type": "object", - }, - "result": {}, - }, - "title": "test_resource", - "type": "object", - }, + "schema": test_resource_schema, "placeholder_keys": { "device": "unilabos_devices", "devices": "unilabos_devices", diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index c6715495..63eda320 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -64,7 +64,7 @@ class DeviceActionStatus: class TestResourceReturn(TypedDict): resources: List[List[ResourceDict]] - devices: List[DeviceSlot] + devices: List[Dict[str, Any]] class TestLatencyReturn(TypedDict): From f9ed6cb3fb42de9cb7139e11a817a78604db0da6 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:02:21 +0800 Subject: [PATCH 3/3] add test_resource_schema --- unilabos/registry/registry.py | 2 +- unilabos/resources/resource_tracker.py | 46 +++++++++++++++++++++++++ unilabos/ros/nodes/presets/host_node.py | 5 ++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 1af0a4e3..844d4cf8 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -92,7 +92,7 @@ class Registry: test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {}) test_resource_schema = self._generate_unilab_json_command_schema( test_resource_method_info.get("args", []), - "auto-test_resource", + "test_resource", test_resource_method_info.get("return_annotation"), ) test_resource_schema["description"] = "用于测试物料、设备和样本。" diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 2054e2ab..e042ef80 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -38,24 +38,52 @@ class LabSample(TypedDict): extra: Dict[str, Any] +class ResourceDictPositionSizeType(TypedDict): + depth: float + width: float + height: float + + class ResourceDictPositionSize(BaseModel): depth: float = Field(description="Depth", default=0.0) # z width: float = Field(description="Width", default=0.0) # x height: float = Field(description="Height", default=0.0) # y +class ResourceDictPositionScaleType(TypedDict): + x: float + y: float + z: float + + class ResourceDictPositionScale(BaseModel): x: float = Field(description="x scale", default=0.0) y: float = Field(description="y scale", default=0.0) z: float = Field(description="z scale", default=0.0) +class ResourceDictPositionObjectType(TypedDict): + x: float + y: float + z: float + + class ResourceDictPositionObject(BaseModel): x: float = Field(description="X coordinate", default=0.0) y: float = Field(description="Y coordinate", default=0.0) z: float = Field(description="Z coordinate", default=0.0) +class ResourceDictPositionType(TypedDict): + size: ResourceDictPositionSizeType + scale: ResourceDictPositionScaleType + layout: Literal["2d", "x-y", "z-y", "x-z"] + position: ResourceDictPositionObjectType + position3d: ResourceDictPositionObjectType + rotation: ResourceDictPositionObjectType + cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] + + class ResourceDictPosition(BaseModel): size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize) scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale) @@ -74,6 +102,24 @@ class ResourceDictPosition(BaseModel): ) +class ResourceDictType(TypedDict): + id: str + uuid: str + name: str + description: str + resource_schema: Dict[str, Any] + model: Dict[str, Any] + icon: str + parent_uuid: Optional[str] + parent: Optional["ResourceDictType"] + type: Union[Literal["device"], str] + klass: str + pose: ResourceDictPositionType + config: Dict[str, Any] + data: Dict[str, Any] + extra: Dict[str, Any] + + # 统一的资源字典模型,parent 自动序列化为 parent_uuid,children 不序列化 class ResourceDict(BaseModel): id: str = Field(description="Resource ID") diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 63eda320..30c5d414 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -35,7 +35,7 @@ from unilabos.resources.resource_tracker import ( ResourceTreeInstance, RETURN_UNILABOS_SAMPLES, JSON_UNILABOS_PARAM, - PARAM_SAMPLE_UUIDS, + PARAM_SAMPLE_UUIDS, SampleUUIDsType, LabSample, ) from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.msgs.message_converter import ( @@ -65,6 +65,7 @@ class DeviceActionStatus: class TestResourceReturn(TypedDict): resources: List[List[ResourceDict]] devices: List[Dict[str, Any]] + unilabos_samples: List[LabSample] class TestLatencyReturn(TypedDict): @@ -1582,6 +1583,7 @@ class HostNode(BaseROS2DeviceNode): def test_resource( self, + sample_uuids: SampleUUIDsType, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, @@ -1596,6 +1598,7 @@ class HostNode(BaseROS2DeviceNode): return { "resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(), "devices": [device, *devices], + "unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()] } def handle_pong_response(self, pong_data: dict):