Adapt to new scheduler, sampels, and edge upload format (#230)

* add sample_material

* adapt to new samples sys

* fix pump transfer. fix resource update when protocol & ros callback

* Adapt to new scheduler.
This commit is contained in:
Xuwznln
2026-02-06 00:49:53 +08:00
committed by GitHub
parent 957fb41a6f
commit 341a1b537c
16 changed files with 631 additions and 233 deletions

View File

@@ -452,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**操作步骤:** **操作步骤:**
1. 将两个 `container` 拖拽到 `workstation` 1. 将两个 `container` 拖拽到 `workstation`
2.`virtual_transfer_pump` 拖拽到 `workstation` 2.`virtual_multiway_valve` 拖拽到 `workstation`
3. 在画布上连接它们(建立父子关系) 3. `virtual_transfer_pump` 拖拽到 `workstation`
4. 在画布上连接它们(建立父子关系)
![设备连接](image/links.png) ![设备连接](image/links.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -54,7 +54,7 @@ class JobAddReq(BaseModel):
action_type: str = Field( action_type: str = Field(
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default="" examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
) )
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid", default_factory=dict) sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict) action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")

View File

@@ -657,6 +657,8 @@ class MessageProcessor:
async def _handle_job_start(self, data: Dict[str, Any]): async def _handle_job_start(self, data: Dict[str, Any]):
"""处理job_start消息""" """处理job_start消息"""
try: try:
if not data.get("sample_material"):
data["sample_material"] = {}
req = JobAddReq(**data) req = JobAddReq(**data)
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)

View File

@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
return total_volume return total_volume
def is_integrated_pump(node_name): def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
return "pump" in node_name and "valve" in node_name """
判断是否为泵阀一体设备
"""
class_lower = (node_class or "").lower()
name_lower = (node_name or "").lower()
if "pump" not in class_lower and "pump" not in name_lower:
return False
integrated_markers = [
"valve",
"pump_valve",
"pumpvalve",
"integrated",
"transfer_pump",
]
for marker in integrated_markers:
if marker in class_lower or marker in name_lower:
return True
return False
def find_connected_pump(G, valve_node): def find_connected_pump(G, valve_node):
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}") debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
for node in filtered_backbone: for node in filtered_backbone:
if is_integrated_pump(G.nodes[node]["class"]): node_data = G.nodes.get(node, {})
node_class = node_data.get("class", "") or ""
if is_integrated_pump(node_class, node):
pumps_from_node[node] = node pumps_from_node[node] = node
valve_from_node[node] = node valve_from_node[node] = node
debug_print(f" - 集成泵-阀: {node}") debug_print(f" - 集成泵-阀: {node}")

View File

@@ -690,16 +690,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
) )
def set_liquid_from_plate( def set_liquid_from_plate(
self, plate: List[ResourceSlot], well_names: list[str], liquid_names: list[str], volumes: list[float] self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
) -> SetLiquidFromPlateReturn: ) -> SetLiquidFromPlateReturn:
"""Set the liquid in wells of a plate by well names (e.g., A1, A2, B3). """Set the liquid in wells of a plate by well names (e.g., A1, A2, B3).
如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。 如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。
""" """
if isinstance(plate, list): # 未来移除
plate = plate[0]
assert issubclass(plate.__class__, Plate), "plate must be a Plate" assert issubclass(plate.__class__, Plate), "plate must be a Plate"
plate: Plate = cast(Plate, plate) plate: Plate = cast(Plate, cast(Resource, plate))
# 根据 well_names 获取对应的 Well 对象 # 根据 well_names 获取对应的 Well 对象
wells = [plate.get_well(name) for name in well_names] wells = [plate.get_well(name) for name in well_names]
res_volumes = [] res_volumes = []

View File

@@ -595,7 +595,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
return super().set_liquid(wells, liquid_names, volumes) return super().set_liquid(wells, liquid_names, volumes)
def set_liquid_from_plate( def set_liquid_from_plate(
self, plate: List[ResourceSlot], well_names: list[str], liquid_names: list[str], volumes: list[float] self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
) -> SetLiquidFromPlateReturn: ) -> SetLiquidFromPlateReturn:
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes) return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)

View File

@@ -31,14 +31,14 @@ class VirtualTransferPump:
# 从config或kwargs中获取参数确保类型正确 # 从config或kwargs中获取参数确保类型正确
if config: if config:
self.max_volume = float(config.get('max_volume', 25.0)) self.max_volume = float(config.get("max_volume", 25.0))
self.port = config.get('port', 'VIRTUAL') self.port = config.get("port", "VIRTUAL")
else: else:
self.max_volume = float(kwargs.get('max_volume', 25.0)) self.max_volume = float(kwargs.get("max_volume", 25.0))
self.port = kwargs.get('port', 'VIRTUAL') self.port = kwargs.get("port", "VIRTUAL")
self._transfer_rate = float(kwargs.get('transfer_rate', 0)) self._transfer_rate = float(kwargs.get("transfer_rate", 0))
self.mode = kwargs.get('mode', VirtualPumpMode.Normal) self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
# 状态变量 - 确保都是正确类型 # 状态变量 - 确保都是正确类型
self._status = "Idle" self._status = "Idle"
@@ -54,7 +54,9 @@ class VirtualTransferPump:
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}") self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨") print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s") 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}") print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
def post_init(self, ros_node: BaseROS2DeviceNode): def post_init(self, ros_node: BaseROS2DeviceNode):
@@ -189,7 +191,9 @@ class VirtualTransferPump:
operation_emoji = "📍" operation_emoji = "📍"
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}") self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)") self.logger.info(
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
)
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s") self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s") self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
@@ -207,7 +211,11 @@ class VirtualTransferPump:
for i in range(steps + 1): for i in range(steps + 1):
# 计算当前位置和进度 # 计算当前位置和进度
progress = (i / steps) * 100 if steps > 0 else 100 progress = (i / steps) * 100 if steps > 0 else 100
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position current_pos = (
start_position + (target_position - start_position) * (i / steps)
if steps > 0
else target_position
)
# 更新状态 # 更新状态
if i < steps: if i < steps:
@@ -244,7 +252,9 @@ class VirtualTransferPump:
# 📊 最终状态日志 # 📊 最终状态日志
if volume_to_move > 0.01: if volume_to_move > 0.01:
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL") self.logger.info(
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
)
# 返回符合action定义的结果 # 返回符合action定义的结果
return { return {
@@ -252,7 +262,7 @@ class VirtualTransferPump:
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})", "message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
"final_position": self._position, "final_position": self._position,
"final_volume": self._current_volume, "final_volume": self._current_volume,
"operation_type": operation_type "operation_type": operation_type,
} }
except Exception as e: except Exception as e:
@@ -262,7 +272,7 @@ class VirtualTransferPump:
"success": False, "success": False,
"message": error_msg, "message": error_msg,
"final_position": self._position, "final_position": self._position,
"final_volume": self._current_volume "final_volume": self._current_volume,
} }
# 其他泵操作方法 # 其他泵操作方法
@@ -388,7 +398,9 @@ class VirtualTransferPump:
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差 return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
def __str__(self): def __str__(self):
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})" return (
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
)
def __repr__(self): def __repr__(self):
return self.__str__() return self.__str__()

View File

@@ -11,9 +11,10 @@ Virtual Workbench Device - 模拟工作台设备
注意:调用来自线程池,使用 threading.Lock 进行同步 注意:调用来自线程池,使用 threading.Lock 进行同步
""" """
import logging import logging
import time import time
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, List
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from threading import Lock, RLock from threading import Lock, RLock
@@ -22,37 +23,46 @@ from typing_extensions import TypedDict
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.utils.decorator import not_action from unilabos.utils.decorator import not_action
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
# ============ TypedDict 返回类型定义 ============ # ============ TypedDict 返回类型定义 ============
class MoveToHeatingStationResult(TypedDict): class MoveToHeatingStationResult(TypedDict):
"""move_to_heating_station 返回类型""" """move_to_heating_station 返回类型"""
success: bool success: bool
station_id: int station_id: int
material_id: str material_id: str
material_number: int material_number: int
message: str message: str
unilabos_samples: List[LabSample]
class StartHeatingResult(TypedDict): class StartHeatingResult(TypedDict):
"""start_heating 返回类型""" """start_heating 返回类型"""
success: bool success: bool
station_id: int station_id: int
material_id: str material_id: str
material_number: int material_number: int
message: str message: str
unilabos_samples: List[LabSample]
class MoveToOutputResult(TypedDict): class MoveToOutputResult(TypedDict):
"""move_to_output 返回类型""" """move_to_output 返回类型"""
success: bool success: bool
station_id: int station_id: int
material_id: str material_id: str
unilabos_samples: List[LabSample]
class PrepareMaterialsResult(TypedDict): class PrepareMaterialsResult(TypedDict):
"""prepare_materials 返回类型 - 批量准备物料""" """prepare_materials 返回类型 - 批量准备物料"""
success: bool success: bool
count: int count: int
material_1: int # 物料编号1 material_1: int # 物料编号1
@@ -61,12 +71,15 @@ class PrepareMaterialsResult(TypedDict):
material_4: int # 物料编号4 material_4: int # 物料编号4
material_5: int # 物料编号5 material_5: int # 物料编号5
message: str message: str
unilabos_samples: List[LabSample]
# ============ 状态枚举 ============ # ============ 状态枚举 ============
class HeatingStationState(Enum): class HeatingStationState(Enum):
"""加热台状态枚举""" """加热台状态枚举"""
IDLE = "idle" # 空闲 IDLE = "idle" # 空闲
OCCUPIED = "occupied" # 已放置物料,等待加热 OCCUPIED = "occupied" # 已放置物料,等待加热
HEATING = "heating" # 加热中 HEATING = "heating" # 加热中
@@ -75,6 +88,7 @@ class HeatingStationState(Enum):
class ArmState(Enum): class ArmState(Enum):
"""机械臂状态枚举""" """机械臂状态枚举"""
IDLE = "idle" # 空闲 IDLE = "idle" # 空闲
BUSY = "busy" # 工作中 BUSY = "busy" # 工作中
@@ -82,6 +96,7 @@ class ArmState(Enum):
@dataclass @dataclass
class HeatingStation: class HeatingStation:
"""加热台数据结构""" """加热台数据结构"""
station_id: int station_id: int
state: HeatingStationState = HeatingStationState.IDLE state: HeatingStationState = HeatingStationState.IDLE
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2") current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
@@ -137,8 +152,7 @@ class VirtualWorkbench:
# 加热台状态 (station_id -> HeatingStation) - 立即初始化不依赖initialize() # 加热台状态 (station_id -> HeatingStation) - 立即初始化不依赖initialize()
self._heating_stations: Dict[int, HeatingStation] = { self._heating_stations: Dict[int, HeatingStation] = {
i: HeatingStation(station_id=i) i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
for i in range(1, self.NUM_HEATING_STATIONS + 1)
} }
self._stations_lock = RLock() # 可重入锁,保护加热台状态 self._stations_lock = RLock() # 可重入锁,保护加热台状态
@@ -178,14 +192,16 @@ class VirtualWorkbench:
station.heating_progress = 0.0 station.heating_progress = 0.0
# 初始化状态 # 初始化状态
self.data.update({ self.data.update(
"status": "Ready", {
"arm_state": ArmState.IDLE.value, "status": "Ready",
"arm_current_task": None, "arm_state": ArmState.IDLE.value,
"heating_stations": self._get_stations_status(), "arm_current_task": None,
"active_tasks_count": 0, "heating_stations": self._get_stations_status(),
"message": "工作台就绪", "active_tasks_count": 0,
}) "message": "工作台就绪",
}
)
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪") self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
return True return True
@@ -204,12 +220,14 @@ class VirtualWorkbench:
with self._tasks_lock: with self._tasks_lock:
self._active_tasks.clear() self._active_tasks.clear()
self.data.update({ self.data.update(
"status": "Offline", {
"arm_state": ArmState.IDLE.value, "status": "Offline",
"heating_stations": {}, "arm_state": ArmState.IDLE.value,
"message": "工作台已关闭", "heating_stations": {},
}) "message": "工作台已关闭",
}
)
return True return True
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]: def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
@@ -227,12 +245,14 @@ class VirtualWorkbench:
def _update_data_status(self, message: Optional[str] = None): def _update_data_status(self, message: Optional[str] = None):
"""更新状态数据""" """更新状态数据"""
self.data.update({ self.data.update(
"arm_state": self._arm_state.value, {
"arm_current_task": self._arm_current_task, "arm_state": self._arm_state.value,
"heating_stations": self._get_stations_status(), "arm_current_task": self._arm_current_task,
"active_tasks_count": len(self._active_tasks), "heating_stations": self._get_stations_status(),
}) "active_tasks_count": len(self._active_tasks),
}
)
if message: if message:
self.data["message"] = message self.data["message"] = message
@@ -280,6 +300,7 @@ class VirtualWorkbench:
def prepare_materials( def prepare_materials(
self, self,
sample_uuids: SampleUUIDsType,
count: int = 5, count: int = 5,
) -> PrepareMaterialsResult: ) -> PrepareMaterialsResult:
""" """
@@ -297,10 +318,7 @@ class VirtualWorkbench:
# 生成物料列表 A1 - A{count} # 生成物料列表 A1 - A{count}
materials = [i for i in range(1, count + 1)] materials = [i for i in range(1, count + 1)]
self.logger.info( self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
f"[准备物料] 生成 {count} 个物料: "
f"A1-A{count} -> material_1~material_{count}"
)
return { return {
"success": True, "success": True,
@@ -311,10 +329,12 @@ class VirtualWorkbench:
"material_4": materials[3] if len(materials) > 3 else 0, "material_4": materials[3] if len(materials) > 3 else 0,
"material_5": materials[4] if len(materials) > 4 else 0, "material_5": materials[4] if len(materials) > 4 else 0,
"message": f"已准备 {count} 个物料: A1-A{count}", "message": f"已准备 {count} 个物料: A1-A{count}",
"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 move_to_heating_station( def move_to_heating_station(
self, self,
sample_uuids: SampleUUIDsType,
material_number: int, material_number: int,
) -> MoveToHeatingStationResult: ) -> MoveToHeatingStationResult:
""" """
@@ -391,6 +411,9 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"material_number": material_number, "material_number": material_number,
"message": f"{material_id}已成功移动到加热台{station_id}", "message": f"{material_id}已成功移动到加热台{station_id}",
"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()]
} }
except Exception as e: except Exception as e:
@@ -403,10 +426,14 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"material_number": material_number, "material_number": material_number,
"message": f"移动失败: {str(e)}", "message": f"移动失败: {str(e)}",
"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 start_heating( def start_heating(
self, self,
sample_uuids: SampleUUIDsType,
station_id: int, station_id: int,
material_number: int, material_number: int,
) -> StartHeatingResult: ) -> StartHeatingResult:
@@ -429,6 +456,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"material_number": material_number, "material_number": material_number,
"message": f"无效的加热台ID: {station_id}", "message": f"无效的加热台ID: {station_id}",
"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()]
} }
with self._stations_lock: with self._stations_lock:
@@ -441,6 +471,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"material_number": material_number, "material_number": material_number,
"message": f"加热台{station_id}上没有物料", "message": f"加热台{station_id}上没有物料",
"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()]
} }
if station.state == HeatingStationState.HEATING: if station.state == HeatingStationState.HEATING:
@@ -450,6 +483,9 @@ class VirtualWorkbench:
"material_id": station.current_material, "material_id": station.current_material,
"material_number": material_number, "material_number": material_number,
"message": f"加热台{station_id}已经在加热中", "message": f"加热台{station_id}已经在加热中",
"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()]
} }
material_id = station.current_material material_id = station.current_material
@@ -499,10 +535,14 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"material_number": material_number, "material_number": material_number,
"message": f"加热台{station_id}加热完成", "message": f"加热台{station_id}加热完成",
"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 move_to_output( def move_to_output(
self, self,
sample_uuids: SampleUUIDsType,
station_id: int, station_id: int,
material_number: int, material_number: int,
) -> MoveToOutputResult: ) -> MoveToOutputResult:
@@ -525,6 +565,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"output_position": f"C{output_number}", "output_position": f"C{output_number}",
"message": f"无效的加热台ID: {station_id}", "message": f"无效的加热台ID: {station_id}",
"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()]
} }
with self._stations_lock: with self._stations_lock:
@@ -538,6 +581,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"output_position": f"C{output_number}", "output_position": f"C{output_number}",
"message": f"加热台{station_id}上没有物料", "message": f"加热台{station_id}上没有物料",
"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()]
} }
if station.state != HeatingStationState.COMPLETED: if station.state != HeatingStationState.COMPLETED:
@@ -547,6 +593,9 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"output_position": f"C{output_number}", "output_position": f"C{output_number}",
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})", "message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
"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()]
} }
output_position = f"C{output_number}" output_position = f"C{output_number}"
@@ -595,6 +644,9 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"output_position": output_position, "output_position": output_position,
"message": f"{material_id}已成功移动到{output_position}", "message": f"{material_id}已成功移动到{output_position}",
"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()]
} }
except Exception as e: except Exception as e:
@@ -607,6 +659,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"output_position": output_position, "output_position": output_position,
"message": f"移动失败: {str(e)}", "message": f"移动失败: {str(e)}",
"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()]
} }
# ============ 状态属性 ============ # ============ 状态属性 ============

View File

@@ -9468,7 +9468,7 @@ liquid_handler.prcxi:
well_names: null well_names: null
handles: handles:
input: input:
- data_key: plate - data_key: '@this.0@@@plate'
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: input_plate handler_key: input_plate
@@ -9503,81 +9503,78 @@ liquid_handler.prcxi:
type: string type: string
type: array type: array
plate: plate:
items: properties:
properties: category:
category: type: string
children:
items:
type: string type: string
children: type: array
items: config:
type: string type: string
type: array data:
config: type: string
type: string id:
data: type: string
type: string name:
id: type: string
type: string parent:
name: type: string
type: string pose:
parent: properties:
type: string orientation:
pose: properties:
properties: w:
orientation: type: number
properties: x:
w: type: number
type: number y:
x: type: number
type: number z:
y: type: number
type: number required:
z: - x
type: number - y
required: - z
- x - w
- y title: orientation
- z type: object
- w position:
title: orientation properties:
type: object x:
position: type: number
properties: y:
x: type: number
type: number z:
y: type: number
type: number required:
z: - x
type: number - y
required: - z
- x title: position
- y type: object
- z required:
title: position - position
type: object - orientation
required: title: pose
- position type: object
- orientation sample_id:
title: pose type: string
type: object type:
sample_id: type: string
type: string required:
type: - id
type: string - name
required: - sample_id
- id - children
- name - parent
- sample_id - type
- children - category
- parent - pose
- type - config
- category - data
- pose
- config
- data
title: plate
type: object
title: plate title: plate
type: array type: object
volumes: volumes:
items: items:
type: number type: number
@@ -9593,17 +9590,207 @@ liquid_handler.prcxi:
- volumes - volumes
type: object type: object
result: result:
$defs:
ResourceDict:
properties:
class:
description: Resource class name
title: Class
type: string
config:
additionalProperties: true
description: Resource configuration
title: Config
type: object
data:
additionalProperties: true
description: 'Resource data, eg: container liquid data'
title: Data
type: object
description:
default: ''
description: Resource description
title: Description
type: string
extra:
additionalProperties: true
description: 'Extra data, eg: slot index'
title: Extra
type: object
icon:
default: ''
description: Resource icon
title: Icon
type: string
id:
description: Resource ID
title: Id
type: string
model:
additionalProperties: true
description: Resource model
title: Model
type: object
name:
description: Resource name
title: Name
type: string
parent:
anyOf:
- $ref: '#/$defs/ResourceDict'
- type: 'null'
default: null
description: Parent resource object
parent_uuid:
anyOf:
- type: string
- type: 'null'
default: null
description: Parent resource uuid
title: Parent Uuid
pose:
$ref: '#/$defs/ResourceDictPosition'
description: Resource position
schema:
additionalProperties: true
description: Resource schema
title: Schema
type: object
type:
anyOf:
- const: device
type: string
- type: string
description: Resource type
title: Type
uuid:
description: Resource UUID
title: Uuid
type: string
required:
- id
- uuid
- name
- type
- class
- config
- data
- extra
title: ResourceDict
type: object
ResourceDictPosition:
properties:
cross_section_type:
default: rectangle
description: Cross section type
enum:
- rectangle
- circle
- rounded_rectangle
title: Cross Section Type
type: string
layout:
default: x-y
description: Resource layout
enum:
- 2d
- x-y
- z-y
- x-z
title: Layout
type: string
position:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource position
position3d:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource position in 3D space
rotation:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource rotation
scale:
$ref: '#/$defs/ResourceDictPositionScale'
description: Resource scale
size:
$ref: '#/$defs/ResourceDictPositionSize'
description: Resource size
title: ResourceDictPosition
type: object
ResourceDictPositionObject:
properties:
x:
default: 0.0
description: X coordinate
title: X
type: number
y:
default: 0.0
description: Y coordinate
title: Y
type: number
z:
default: 0.0
description: Z coordinate
title: Z
type: number
title: ResourceDictPositionObject
type: object
ResourceDictPositionScale:
properties:
x:
default: 0.0
description: x scale
title: X
type: number
y:
default: 0.0
description: y scale
title: Y
type: number
z:
default: 0.0
description: z scale
title: Z
type: number
title: ResourceDictPositionScale
type: object
ResourceDictPositionSize:
properties:
depth:
default: 0.0
description: Depth
title: Depth
type: number
height:
default: 0.0
description: Height
title: Height
type: number
width:
default: 0.0
description: Width
title: Width
type: number
title: ResourceDictPositionSize
type: object
properties: properties:
plate: plate:
items: {} items:
items:
$ref: '#/$defs/ResourceDict'
type: array
title: Plate title: Plate
type: array type: array
volumes: volumes:
items: {} items:
type: number
title: Volumes title: Volumes
type: array type: array
wells: wells:
items: {} items:
items:
$ref: '#/$defs/ResourceDict'
type: array
title: Wells title: Wells
type: array type: array
required: required:

View File

@@ -5835,6 +5835,25 @@ virtual_workbench:
- material_number - material_number
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: move_to_heating_station 返回类型 description: move_to_heating_station 返回类型
properties: properties:
material_id: material_id:
@@ -5853,12 +5872,18 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- station_id - station_id
- material_id - material_id
- material_number - material_number
- message - message
- unilabos_samples
title: MoveToHeatingStationResult title: MoveToHeatingStationResult
type: object type: object
required: required:
@@ -5903,6 +5928,25 @@ virtual_workbench:
- material_number - material_number
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: move_to_output 返回类型 description: move_to_output 返回类型
properties: properties:
material_id: material_id:
@@ -5914,10 +5958,16 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- station_id - station_id
- material_id - material_id
- unilabos_samples
title: MoveToOutputResult title: MoveToOutputResult
type: object type: object
required: required:
@@ -5972,6 +6022,25 @@ virtual_workbench:
required: [] required: []
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: prepare_materials 返回类型 - 批量准备物料 description: prepare_materials 返回类型 - 批量准备物料
properties: properties:
count: count:
@@ -5998,6 +6067,11 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- count - count
@@ -6007,6 +6081,7 @@ virtual_workbench:
- material_4 - material_4
- material_5 - material_5
- message - message
- unilabos_samples
title: PrepareMaterialsResult title: PrepareMaterialsResult
type: object type: object
required: required:
@@ -6062,6 +6137,25 @@ virtual_workbench:
- material_number - material_number
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: start_heating 返回类型 description: start_heating 返回类型
properties: properties:
material_id: material_id:
@@ -6079,12 +6173,18 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- station_id - station_id
- material_id - material_id
- material_number - material_number
- message - message
- unilabos_samples
title: StartHeatingResult title: StartHeatingResult
type: object type: object
required: required:

View File

@@ -5,6 +5,8 @@ from pydantic import BaseModel, field_serializer, field_validator, ValidationErr
from pydantic import Field from pydantic import Field
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
from typing_extensions import TypedDict
from unilabos.resources.plr_additional_res_reg import register from unilabos.resources.plr_additional_res_reg import register
from unilabos.utils.log import logger from unilabos.utils.log import logger
@@ -30,6 +32,12 @@ RETURN_UNILABOS_SAMPLES = "unilabos_samples"
SampleUUIDsType = Dict[str, Optional["PLRResource"]] SampleUUIDsType = Dict[str, Optional["PLRResource"]]
class LabSample(TypedDict):
sample_uuid: str
oss_path: str
extra: Dict[str, Any]
class ResourceDictPositionSize(BaseModel): class ResourceDictPositionSize(BaseModel):
depth: float = Field(description="Depth", default=0.0) # z depth: float = Field(description="Depth", default=0.0) # z
width: float = Field(description="Width", default=0.0) # x width: float = Field(description="Width", default=0.0) # x

View File

@@ -412,16 +412,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else: else:
for r in rts.root_nodes: for r in rts.root_nodes:
r.res_content.parent_uuid = self.uuid r.res_content.parent_uuid = self.uuid
rts_plr_instances = rts.to_plr_resources()
if ( if len(rts.root_nodes) == 1 and isinstance(rts_plr_instances[0], RegularContainer):
len(LIQUID_INPUT_SLOT)
and LIQUID_INPUT_SLOT[0] == -1
and len(rts.root_nodes) == 1
and isinstance(rts.root_nodes[0], RegularContainer)
):
# noinspection PyTypeChecker # noinspection PyTypeChecker
container_instance: RegularContainer = rts.root_nodes[0] container_instance: RegularContainer = rts_plr_instances[0]
found_resources = self.resource_tracker.figure_resource({"id": container_instance.name}, try_mode=True) found_resources = self.resource_tracker.figure_resource({"name": container_instance.name}, try_mode=True)
if not len(found_resources): if not len(found_resources):
self.resource_tracker.add_resource(container_instance) self.resource_tracker.add_resource(container_instance)
logger.info(f"添加物料{container_instance.name}到资源跟踪器") logger.info(f"添加物料{container_instance.name}到资源跟踪器")
@@ -430,7 +425,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
found_resource = found_resources[0] found_resource = found_resources[0]
if isinstance(found_resource, RegularContainer): if isinstance(found_resource, RegularContainer):
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}") logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
found_resource.state.update(json.loads(container_instance.state)) found_resource.state.update(container_instance.state)
elif isinstance(found_resource, dict): elif isinstance(found_resource, dict):
raise ValueError("已不支持 字典 版本的RegularContainer") raise ValueError("已不支持 字典 版本的RegularContainer")
else: else:
@@ -443,7 +438,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
"action": "add", "action": "add",
"data": { "data": {
"data": rts.dump(), "data": rts.dump(),
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "", "mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else self.uuid,
"first_add": False, "first_add": False,
}, },
} }
@@ -1538,11 +1533,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if isinstance(rs, list): if isinstance(rs, list):
for r in rs: for r in rs:
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象 res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
if res is None:
res = rs
if id(res) not in seen:
seen.add(id(res))
unique_resources.append(res)
else: else:
res = self.resource_tracker.parent_resource(rs) res = self.resource_tracker.parent_resource(rs)
if id(res) not in seen: if res is None:
seen.add(id(res)) res = rs
unique_resources.append(res) if id(res) not in seen:
seen.add(id(res))
unique_resources.append(res)
# 使用新的资源树接口 # 使用新的资源树接口
if unique_resources: if unique_resources:

View File

@@ -863,7 +863,7 @@ class HostNode(BaseROS2DeviceNode):
f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}" f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}"
f"{'...' if len(unilabos_samples) > 5 else ''}" f"{'...' if len(unilabos_samples) > 5 else ''}"
) )
return_info[RETURN_UNILABOS_SAMPLES] = unilabos_samples return_info["samples"] = unilabos_samples
suc = return_info.get("suc", False) suc = return_info.get("suc", False)
if not suc: if not suc:
status = "failed" status = "failed"

View File

@@ -340,6 +340,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False) plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
# 获取父资源 # 获取父资源
res = self.resource_tracker.parent_resource(plr) res = self.resource_tracker.parent_resource(plr)
if res is None:
res = plr
if id(res) not in seen: if id(res) not in seen:
seen.add(id(res)) seen.add(id(res))
unique_resources.append(res) unique_resources.append(res)

View File

@@ -52,7 +52,8 @@ class DeviceClassCreator(Generic[T]):
if self.device_instance is not None: if self.device_instance is not None:
for c in self.children: for c in self.children:
if c.res_content.type != "device": if c.res_content.type != "device":
self.resource_tracker.add_resource(c.get_plr_nested_dict()) res = ResourceTreeSet([ResourceTreeInstance(c)]).to_plr_resources()[0]
self.resource_tracker.add_resource(res)
def create_instance(self, data: Dict[str, Any]) -> T: def create_instance(self, data: Dict[str, Any]) -> T:
""" """
@@ -119,7 +120,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
# return resource, source_type # return resource, source_type
def _process_resource_references( def _process_resource_references(
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None self, data: Any, processed_child_names: Optional[Dict[str, Any]], to_dict=False, states=None, prefix_path="", name_to_uuid=None
) -> Any: ) -> Any:
""" """
递归处理资源引用替换_resource_child_name对应的资源 递归处理资源引用替换_resource_child_name对应的资源
@@ -164,6 +165,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
states[prefix_path] = resource_instance.serialize_all_state() states[prefix_path] = resource_instance.serialize_all_state()
return serialized return serialized
else: else:
processed_child_names[child_name] = resource_instance
self.resource_tracker.add_resource(resource_instance) self.resource_tracker.add_resource(resource_instance)
# 立即设置UUIDstate已经在resource_ulab_to_plr中处理过了 # 立即设置UUIDstate已经在resource_ulab_to_plr中处理过了
if name_to_uuid: if name_to_uuid:
@@ -182,12 +184,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
result = {} result = {}
for key, value in data.items(): for key, value in data.items():
new_prefix = f"{prefix_path}.{key}" if prefix_path else key new_prefix = f"{prefix_path}.{key}" if prefix_path else key
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid) result[key] = self._process_resource_references(value, processed_child_names, to_dict, states, new_prefix, name_to_uuid)
return result return result
elif isinstance(data, list): elif isinstance(data, list):
return [ return [
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid) self._process_resource_references(item, processed_child_names, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
for i, item in enumerate(data) for i, item in enumerate(data)
] ]
@@ -234,7 +236,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
# 首先处理资源引用 # 首先处理资源引用
states = {} states = {}
processed_data = self._process_resource_references( processed_data = self._process_resource_references(
data, to_dict=True, states=states, name_to_uuid=name_to_uuid data, {}, to_dict=True, states=states, name_to_uuid=name_to_uuid
) )
try: try:
@@ -270,7 +272,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
arg_value = spec_args[param_name].annotation arg_value = spec_args[param_name].annotation
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}") logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid) processed_child_names = {}
processed_data = self._process_resource_references(data, processed_child_names, to_dict=False, name_to_uuid=name_to_uuid)
for child_name, resource_instance in processed_data.items():
for ind, name in enumerate([child.res_content.name for child in self.children]):
if name == child_name:
self.children.pop(ind)
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用调用的自身的attach_resource self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用调用的自身的attach_resource
except Exception as e: except Exception as e:
logger.error(f"PyLabRobot创建实例失败: {e}") logger.error(f"PyLabRobot创建实例失败: {e}")
@@ -342,9 +349,10 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
try: try:
# 创建实例额外补充一个给protocol node的字段后面考虑取消 # 创建实例额外补充一个给protocol node的字段后面考虑取消
data["children"] = self.children data["children"] = self.children
for child in self.children: # super(WorkstationNodeCreator, self).create_instance(data)的时候会attach
if child.res_content.type != "device": # for child in self.children:
self.resource_tracker.add_resource(child.get_plr_nested_dict()) # if child.res_content.type != "device":
# self.resource_tracker.add_resource(child.get_plr_nested_dict())
deck_dict = data.get("deck") deck_dict = data.get("deck")
if deck_dict: if deck_dict:
from pylabrobot.resources import Deck, Resource from pylabrobot.resources import Deck, Resource