mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-05 22:15:04 +00:00
Compare commits
6 Commits
56eb7e2ab4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
341a1b537c | ||
|
|
957fb41a6f | ||
|
|
26271bcab8 | ||
|
|
84a8223173 | ||
|
|
e8d1263488 | ||
|
|
380b39100d |
@@ -46,7 +46,7 @@ requirements:
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- opcua
|
||||
- opcua # [not osx]
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
|
||||
@@ -452,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**操作步骤:**
|
||||
|
||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
3. 在画布上连接它们(建立父子关系)
|
||||
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
4. 在画布上连接它们(建立父子关系)
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 415 KiB |
@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
|
||||
action_type: str = Field(
|
||||
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")
|
||||
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="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
|
||||
@@ -327,6 +327,7 @@ def job_add(req: JobAddReq) -> JobData:
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ class MessageProcessor:
|
||||
try:
|
||||
message_str = json.dumps(msg, ensure_ascii=False)
|
||||
await self.websocket.send(message_str)
|
||||
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -657,6 +657,8 @@ class MessageProcessor:
|
||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||
"""处理job_start消息"""
|
||||
try:
|
||||
if not data.get("sample_material"):
|
||||
data["sample_material"] = {}
|
||||
req = JobAddReq(**data)
|
||||
|
||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||
@@ -688,6 +690,7 @@ class MessageProcessor:
|
||||
queue_item,
|
||||
action_type=req.action_type,
|
||||
action_kwargs=req.action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=req.server_info,
|
||||
)
|
||||
|
||||
@@ -1301,7 +1304,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
|
||||
def publish_job_status(
|
||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||
|
||||
@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
return total_volume
|
||||
|
||||
|
||||
def is_integrated_pump(node_name):
|
||||
return "pump" in node_name and "valve" in node_name
|
||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
"""
|
||||
判断是否为泵阀一体设备
|
||||
"""
|
||||
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):
|
||||
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
|
||||
debug_print(f"🔧 过滤后的骨架: {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
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 集成泵-阀: {node}")
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
from collections import Counter
|
||||
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
|
||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||
from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod
|
||||
from pylabrobot.liquid_handling.standard import GripDirection
|
||||
from pylabrobot.resources import (
|
||||
@@ -27,26 +23,38 @@ from pylabrobot.resources import (
|
||||
Trash,
|
||||
Tip,
|
||||
)
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceTreeSet,
|
||||
ResourceDict,
|
||||
EXTRA_SAMPLE_UUID,
|
||||
EXTRA_UNILABOS_SAMPLE_UUID,
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||
|
||||
|
||||
class SimpleReturn(TypedDict):
|
||||
samples: list
|
||||
volumes: list
|
||||
samples: List[List[ResourceDict]]
|
||||
volumes: List[float]
|
||||
|
||||
|
||||
class SetLiquidReturn(TypedDict):
|
||||
wells: list
|
||||
volumes: list
|
||||
wells: List[List[ResourceDict]]
|
||||
volumes: List[float]
|
||||
|
||||
|
||||
class SetLiquidFromPlateReturn(TypedDict):
|
||||
plate: list
|
||||
wells: list
|
||||
volumes: list
|
||||
plate: List[List[ResourceDict]]
|
||||
wells: List[List[ResourceDict]]
|
||||
volumes: List[float]
|
||||
|
||||
|
||||
class TransferLiquidReturn(TypedDict):
|
||||
sources: List[List[ResourceDict]]
|
||||
targets: List[List[ResourceDict]]
|
||||
|
||||
|
||||
class LiquidHandlerMiddleware(LiquidHandler):
|
||||
@@ -228,12 +236,11 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_samples.append(
|
||||
{"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)}
|
||||
)
|
||||
sample_uuid_value = resource.unilabos_extra.get(EXTRA_SAMPLE_UUID, None)
|
||||
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: sample_uuid_value})
|
||||
res_volumes.append(volume)
|
||||
self.pending_liquids_dict[channel] = {
|
||||
"sample_uuid": resource.unilabos_extra.get("sample_uuid", None),
|
||||
EXTRA_SAMPLE_UUID: sample_uuid_value,
|
||||
"volume": volume,
|
||||
}
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
@@ -275,10 +282,10 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_uuid = self.pending_liquids_dict[channel]["sample_uuid"]
|
||||
res_uuid = self.pending_liquids_dict[channel][EXTRA_SAMPLE_UUID]
|
||||
self.pending_liquids_dict[channel]["volume"] -= volume
|
||||
resource.unilabos_extra["sample_uuid"] = res_uuid
|
||||
res_samples.append({"name": resource.name, "sample_uuid": res_uuid})
|
||||
resource.unilabos_extra[EXTRA_SAMPLE_UUID] = res_uuid
|
||||
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid})
|
||||
res_volumes.append(volume)
|
||||
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
@@ -682,14 +689,15 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_liquid_from_plate(
|
||||
cls, plate: 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:
|
||||
"""Set the liquid in wells of a plate by well names (e.g., A1, A2, B3).
|
||||
|
||||
如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。
|
||||
"""
|
||||
assert issubclass(plate.__class__, Plate), "plate must be a Plate"
|
||||
plate: Plate = cast(Plate, cast(Resource, plate))
|
||||
# 根据 well_names 获取对应的 Well 对象
|
||||
wells = [plate.get_well(name) for name in well_names]
|
||||
res_volumes = []
|
||||
@@ -706,6 +714,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
well.set_liquids([(liquid_name, volume)]) # type: ignore
|
||||
res_volumes.append(volume)
|
||||
|
||||
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells})
|
||||
submit_time = time.time()
|
||||
while not task.done():
|
||||
if time.time() - submit_time > 10:
|
||||
self._ros_node.lab_logger().info(f"set_liquid_from_plate {plate} 超时")
|
||||
break
|
||||
time.sleep(0.01)
|
||||
|
||||
return SetLiquidFromPlateReturn(
|
||||
plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore
|
||||
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore
|
||||
@@ -1111,7 +1127,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
mix_liquid_height: Optional[float] = None,
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
) -> TransferLiquidReturn:
|
||||
"""Transfer liquid with automatic mode detection.
|
||||
|
||||
Supports three transfer modes:
|
||||
@@ -1251,6 +1267,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
"Supported modes: 1->N, N->1, or N->N."
|
||||
)
|
||||
|
||||
return TransferLiquidReturn(
|
||||
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
|
||||
targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore
|
||||
)
|
||||
|
||||
async def _transfer_one_to_one(
|
||||
self,
|
||||
sources: Sequence[Container],
|
||||
|
||||
@@ -52,6 +52,7 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
SimpleReturn,
|
||||
SetLiquidReturn,
|
||||
SetLiquidFromPlateReturn,
|
||||
TransferLiquidReturn,
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
@@ -154,25 +155,29 @@ class PRCXI9300Plate(Plate):
|
||||
**kwargs,
|
||||
):
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
items = None
|
||||
ordering_param = None
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 Plate 自己创建 Well 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
if ordering:
|
||||
values = list(ordering.values())
|
||||
value = values[0]
|
||||
if isinstance(value, str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
elif value is None:
|
||||
ordering_param = ordering
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
@@ -713,7 +718,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
mix_liquid_height: Optional[float] = None,
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
) -> TransferLiquidReturn:
|
||||
return await super().transfer_liquid(
|
||||
sources,
|
||||
targets,
|
||||
|
||||
@@ -31,14 +31,14 @@ class VirtualTransferPump:
|
||||
|
||||
# 从config或kwargs中获取参数,确保类型正确
|
||||
if config:
|
||||
self.max_volume = float(config.get('max_volume', 25.0))
|
||||
self.port = config.get('port', 'VIRTUAL')
|
||||
self.max_volume = float(config.get("max_volume", 25.0))
|
||||
self.port = config.get("port", "VIRTUAL")
|
||||
else:
|
||||
self.max_volume = float(kwargs.get('max_volume', 25.0))
|
||||
self.port = kwargs.get('port', 'VIRTUAL')
|
||||
self.max_volume = float(kwargs.get("max_volume", 25.0))
|
||||
self.port = kwargs.get("port", "VIRTUAL")
|
||||
|
||||
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
|
||||
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
|
||||
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
|
||||
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
|
||||
|
||||
# 状态变量 - 确保都是正确类型
|
||||
self._status = "Idle"
|
||||
@@ -54,7 +54,9 @@ class VirtualTransferPump:
|
||||
self.logger = logging.getLogger(f"VirtualTransferPump.{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}")
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
@@ -189,7 +191,9 @@ class VirtualTransferPump:
|
||||
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" ⏰ 预计时间: {display_duration:.2f}s")
|
||||
|
||||
@@ -207,7 +211,11 @@ class VirtualTransferPump:
|
||||
for i in range(steps + 1):
|
||||
# 计算当前位置和进度
|
||||
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:
|
||||
@@ -244,7 +252,9 @@ class VirtualTransferPump:
|
||||
|
||||
# 📊 最终状态日志
|
||||
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定义的结果
|
||||
return {
|
||||
@@ -252,7 +262,7 @@ class VirtualTransferPump:
|
||||
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
||||
"final_position": self._position,
|
||||
"final_volume": self._current_volume,
|
||||
"operation_type": operation_type
|
||||
"operation_type": operation_type,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -262,7 +272,7 @@ class VirtualTransferPump:
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"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) # 允许小量误差
|
||||
|
||||
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):
|
||||
return self.__str__()
|
||||
|
||||
@@ -11,9 +11,10 @@ Virtual Workbench Device - 模拟工作台设备
|
||||
|
||||
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
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.utils.decorator import not_action
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
|
||||
|
||||
class MoveToHeatingStationResult(TypedDict):
|
||||
"""move_to_heating_station 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class StartHeatingResult(TypedDict):
|
||||
"""start_heating 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class MoveToOutputResult(TypedDict):
|
||||
"""move_to_output 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class PrepareMaterialsResult(TypedDict):
|
||||
"""prepare_materials 返回类型 - 批量准备物料"""
|
||||
|
||||
success: bool
|
||||
count: int
|
||||
material_1: int # 物料编号1
|
||||
@@ -61,12 +71,15 @@ class PrepareMaterialsResult(TypedDict):
|
||||
material_4: int # 物料编号4
|
||||
material_5: int # 物料编号5
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
# ============ 状态枚举 ============
|
||||
|
||||
|
||||
class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
@@ -75,6 +88,7 @@ class HeatingStationState(Enum):
|
||||
|
||||
class ArmState(Enum):
|
||||
"""机械臂状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
BUSY = "busy" # 工作中
|
||||
|
||||
@@ -82,6 +96,7 @@ class ArmState(Enum):
|
||||
@dataclass
|
||||
class HeatingStation:
|
||||
"""加热台数据结构"""
|
||||
|
||||
station_id: int
|
||||
state: HeatingStationState = HeatingStationState.IDLE
|
||||
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
||||
@@ -137,8 +152,7 @@ class VirtualWorkbench:
|
||||
|
||||
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||
self._heating_stations: Dict[int, HeatingStation] = {
|
||||
i: HeatingStation(station_id=i)
|
||||
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
}
|
||||
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||
|
||||
@@ -178,14 +192,16 @@ class VirtualWorkbench:
|
||||
station.heating_progress = 0.0
|
||||
|
||||
# 初始化状态
|
||||
self.data.update({
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
})
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
}
|
||||
)
|
||||
|
||||
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
||||
return True
|
||||
@@ -204,12 +220,14 @@ class VirtualWorkbench:
|
||||
with self._tasks_lock:
|
||||
self._active_tasks.clear()
|
||||
|
||||
self.data.update({
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
})
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
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):
|
||||
"""更新状态数据"""
|
||||
self.data.update({
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
})
|
||||
self.data.update(
|
||||
{
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
}
|
||||
)
|
||||
if message:
|
||||
self.data["message"] = message
|
||||
|
||||
@@ -280,6 +300,7 @@ class VirtualWorkbench:
|
||||
|
||||
def prepare_materials(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
count: int = 5,
|
||||
) -> PrepareMaterialsResult:
|
||||
"""
|
||||
@@ -297,10 +318,7 @@ class VirtualWorkbench:
|
||||
# 生成物料列表 A1 - A{count}
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
self.logger.info(
|
||||
f"[准备物料] 生成 {count} 个物料: "
|
||||
f"A1-A{count} -> material_1~material_{count}"
|
||||
)
|
||||
self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -311,10 +329,12 @@ class VirtualWorkbench:
|
||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||
"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(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
material_number: int,
|
||||
) -> MoveToHeatingStationResult:
|
||||
"""
|
||||
@@ -391,6 +411,9 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"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:
|
||||
@@ -403,10 +426,14 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"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(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> StartHeatingResult:
|
||||
@@ -429,6 +456,9 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"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:
|
||||
@@ -441,6 +471,9 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"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:
|
||||
@@ -450,6 +483,9 @@ class VirtualWorkbench:
|
||||
"material_id": station.current_material,
|
||||
"material_number": material_number,
|
||||
"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
|
||||
@@ -499,10 +535,14 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"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(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> MoveToOutputResult:
|
||||
@@ -525,6 +565,9 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"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:
|
||||
@@ -538,6 +581,9 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"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:
|
||||
@@ -547,6 +593,9 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"output_position": f"C{output_number}",
|
||||
"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}"
|
||||
@@ -595,6 +644,9 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"output_position": 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:
|
||||
@@ -607,6 +659,9 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": output_position,
|
||||
"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()]
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@@ -638,7 +638,7 @@ liquid_handler:
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 吸头迭代函数。用于自动管理和切换吸头架中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
|
||||
description: 吸头迭代函数。用于自动管理和切换枪头盒中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -712,6 +712,43 @@ liquid_handler:
|
||||
title: set_group参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_liquid_from_plate:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
liquid_names: null
|
||||
plate: null
|
||||
volumes: null
|
||||
well_names: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
liquid_names:
|
||||
type: string
|
||||
plate:
|
||||
type: string
|
||||
volumes:
|
||||
type: string
|
||||
well_names:
|
||||
type: string
|
||||
required:
|
||||
- plate
|
||||
- well_names
|
||||
- liquid_names
|
||||
- volumes
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_liquid_from_plate参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_tiprack:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -721,7 +758,7 @@ liquid_handler:
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 吸头架设置函数。用于配置和初始化液体处理系统的吸头架信息,包括吸头架位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、吸头架更换、实验配置等需要吸头资源管理的操作场景。
|
||||
description: 枪头盒设置函数。用于配置和初始化液体处理系统的枪头盒信息,包括枪头盒位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、枪头盒更换、实验配置等需要吸头资源管理的操作场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -4093,32 +4130,32 @@ liquid_handler:
|
||||
- 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: liquid
|
||||
- data_key: sources
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: targets
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: liquid
|
||||
label: 待移动液体
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: 转移目标
|
||||
- data_key: tip_racks
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: 枪头盒
|
||||
output:
|
||||
- data_key: sources.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
label: 移液后源孔
|
||||
- data_key: targets.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
label: 移液后目标孔
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
@@ -5114,19 +5151,34 @@ liquid_handler.biomek:
|
||||
- 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: liquid
|
||||
- data_key: sources
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: liquid-input
|
||||
handler_key: sources
|
||||
io_type: target
|
||||
label: Liquid Input
|
||||
label: 待移动液体
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: 转移目标
|
||||
- data_key: tip_racks
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: 枪头盒
|
||||
output:
|
||||
- data_key: liquid
|
||||
- data_key: sources.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: liquid-output
|
||||
handler_key: sources_out
|
||||
io_type: source
|
||||
label: Liquid Output
|
||||
label: 移液后源孔
|
||||
- data_key: targets.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: 移液后目标孔
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
@@ -9416,7 +9468,7 @@ liquid_handler.prcxi:
|
||||
well_names: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: plate
|
||||
- data_key: '@this.0@@@plate'
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: input_plate
|
||||
@@ -9538,9 +9590,195 @@ liquid_handler.prcxi:
|
||||
- volumes
|
||||
type: object
|
||||
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:
|
||||
plate:
|
||||
items: {}
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Plate
|
||||
type: array
|
||||
volumes:
|
||||
@@ -9549,7 +9787,10 @@ liquid_handler.prcxi:
|
||||
title: Volumes
|
||||
type: array
|
||||
wells:
|
||||
items: {}
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Wells
|
||||
type: array
|
||||
required:
|
||||
@@ -9922,18 +10163,18 @@ liquid_handler.prcxi:
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack_identifier
|
||||
label: 墙头盒
|
||||
label: 枪头盒
|
||||
output:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
- data_key: sources.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
label: 移液后源孔
|
||||
- data_key: targets.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
label: 移液后目标孔
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
|
||||
@@ -5835,6 +5835,25 @@ virtual_workbench:
|
||||
- material_number
|
||||
type: object
|
||||
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 返回类型
|
||||
properties:
|
||||
material_id:
|
||||
@@ -5853,12 +5872,18 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- station_id
|
||||
- material_id
|
||||
- material_number
|
||||
- message
|
||||
- unilabos_samples
|
||||
title: MoveToHeatingStationResult
|
||||
type: object
|
||||
required:
|
||||
@@ -5903,6 +5928,25 @@ virtual_workbench:
|
||||
- material_number
|
||||
type: object
|
||||
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 返回类型
|
||||
properties:
|
||||
material_id:
|
||||
@@ -5914,10 +5958,16 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- station_id
|
||||
- material_id
|
||||
- unilabos_samples
|
||||
title: MoveToOutputResult
|
||||
type: object
|
||||
required:
|
||||
@@ -5972,6 +6022,25 @@ virtual_workbench:
|
||||
required: []
|
||||
type: object
|
||||
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 返回类型 - 批量准备物料
|
||||
properties:
|
||||
count:
|
||||
@@ -5998,6 +6067,11 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- count
|
||||
@@ -6007,6 +6081,7 @@ virtual_workbench:
|
||||
- material_4
|
||||
- material_5
|
||||
- message
|
||||
- unilabos_samples
|
||||
title: PrepareMaterialsResult
|
||||
type: object
|
||||
required:
|
||||
@@ -6062,6 +6137,25 @@ virtual_workbench:
|
||||
- material_number
|
||||
type: object
|
||||
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 返回类型
|
||||
properties:
|
||||
material_id:
|
||||
@@ -6079,12 +6173,18 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- station_id
|
||||
- material_id
|
||||
- material_number
|
||||
- message
|
||||
- unilabos_samples
|
||||
title: StartHeatingResult
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -151,12 +151,40 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
||||
"""
|
||||
# 构建 id 到 uuid 的映射
|
||||
id_to_uuid: Dict[str, str] = {}
|
||||
uuid_to_id: Dict[str, str] = {}
|
||||
for node in resource_tree_set.all_nodes:
|
||||
id_to_uuid[node.res_content.id] = node.res_content.uuid
|
||||
uuid_to_id[node.res_content.uuid] = node.res_content.id
|
||||
|
||||
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
||||
for link in links:
|
||||
source_id = link.get("source")
|
||||
target_id = link.get("target")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_id and source_id in id_to_uuid:
|
||||
link["source_uuid"] = id_to_uuid[source_id]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_id and target_id in id_to_uuid:
|
||||
link["target_uuid"] = id_to_uuid[target_id]
|
||||
|
||||
source_uuid = link.get("source_uuid")
|
||||
target_uuid = link.get("target_uuid")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_uuid and source_uuid in uuid_to_id:
|
||||
link["source"] = uuid_to_id[source_uuid]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_uuid and target_uuid in uuid_to_id:
|
||||
link["target"] = uuid_to_id[target_uuid]
|
||||
|
||||
# 第一遍处理:将字符串类型的port转换为字典格式
|
||||
for link in links:
|
||||
port = link.get("port")
|
||||
if port is None:
|
||||
continue
|
||||
if link.get("type", "physical") == "physical":
|
||||
link["type"] = "fluid"
|
||||
if isinstance(port, int):
|
||||
@@ -179,13 +207,15 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
||||
link["port"] = {link["source"]: None, link["target"]: None}
|
||||
|
||||
# 构建边字典,键为(source节点, target节点),值为对应的port信息
|
||||
edges = {(link["source"], link["target"]): link["port"] for link in links}
|
||||
edges = {(link["source"], link["target"]): link["port"] for link in links if link.get("port")}
|
||||
|
||||
# 第二遍处理:填充反向边的dest信息
|
||||
delete_reverses = []
|
||||
for i, link in enumerate(links):
|
||||
s, t = link["source"], link["target"]
|
||||
current_port = link["port"]
|
||||
current_port = link.get("port")
|
||||
if current_port is None:
|
||||
continue
|
||||
if current_port.get(t) is None:
|
||||
reverse_key = (t, s)
|
||||
reverse_port = edges.get(reverse_key)
|
||||
@@ -200,20 +230,6 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
||||
current_port[t] = current_port[s]
|
||||
# 删除已被使用反向端口信息的反向边
|
||||
standardized_links = [link for i, link in enumerate(links) if i not in delete_reverses]
|
||||
|
||||
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
||||
for link in standardized_links:
|
||||
source_id = link.get("source")
|
||||
target_id = link.get("target")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_id and source_id in id_to_uuid:
|
||||
link["source_uuid"] = id_to_uuid[source_id]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_id and target_id in id_to_uuid:
|
||||
link["target_uuid"] = id_to_uuid[target_id]
|
||||
|
||||
return standardized_links
|
||||
|
||||
|
||||
@@ -284,6 +300,8 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
edge["sourceHandle"] = port[source]
|
||||
elif "source_port" in edge:
|
||||
edge["sourceHandle"] = edge.pop("source_port")
|
||||
elif "source_handle" in edge:
|
||||
edge["sourceHandle"] = edge.pop("source_handle")
|
||||
else:
|
||||
typ = edge.get("type")
|
||||
if typ == "communication":
|
||||
@@ -292,6 +310,8 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
edge["targetHandle"] = port[target]
|
||||
elif "target_port" in edge:
|
||||
edge["targetHandle"] = edge.pop("target_port")
|
||||
elif "target_handle" in edge:
|
||||
edge["targetHandle"] = edge.pop("target_handle")
|
||||
else:
|
||||
typ = edge.get("type")
|
||||
if typ == "communication":
|
||||
|
||||
@@ -5,6 +5,8 @@ from pydantic import BaseModel, field_serializer, field_validator, ValidationErr
|
||||
from pydantic import Field
|
||||
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.utils.log import logger
|
||||
|
||||
@@ -14,6 +16,26 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
EXTRA_CLASS = "unilabos_resource_class"
|
||||
EXTRA_SAMPLE_UUID = "sample_uuid"
|
||||
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
|
||||
|
||||
# 函数参数名常量 - 用于自动注入 sample_uuids 列表
|
||||
PARAM_SAMPLE_UUIDS = "sample_uuids"
|
||||
|
||||
# JSON Command 中的系统参数字段名
|
||||
JSON_UNILABOS_PARAM = "unilabos_param"
|
||||
|
||||
# 返回值中的 samples 字段名
|
||||
RETURN_UNILABOS_SAMPLES = "unilabos_samples"
|
||||
|
||||
# sample_uuids 参数类型 (用于 virtual bench 等设备添加 sample_uuids 参数)
|
||||
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
|
||||
|
||||
|
||||
class LabSample(TypedDict):
|
||||
sample_uuid: str
|
||||
oss_path: str
|
||||
extra: Dict[str, Any]
|
||||
|
||||
|
||||
class ResourceDictPositionSize(BaseModel):
|
||||
@@ -529,6 +551,7 @@ class ResourceTreeSet(object):
|
||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.serializer import deserialize
|
||||
|
||||
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
||||
plr_resource.location = location
|
||||
plr_resource.load_all_state(all_states)
|
||||
|
||||
@@ -4,8 +4,20 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \
|
||||
Tuple
|
||||
from typing import (
|
||||
get_type_hints,
|
||||
TypeVar,
|
||||
Generic,
|
||||
Dict,
|
||||
Any,
|
||||
Type,
|
||||
TypedDict,
|
||||
Optional,
|
||||
List,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
@@ -48,6 +60,9 @@ from unilabos.resources.resource_tracker import (
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
ResourceDictInstance,
|
||||
EXTRA_SAMPLE_UUID,
|
||||
PARAM_SAMPLE_UUIDS,
|
||||
JSON_UNILABOS_PARAM,
|
||||
)
|
||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||
from rclpy.task import Task, Future
|
||||
@@ -216,14 +231,15 @@ class PropertyPublisher:
|
||||
|
||||
def publish_property(self):
|
||||
try:
|
||||
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
# self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
value = self.get_property()
|
||||
if self.print_publish:
|
||||
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
pass
|
||||
# self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
if value is not None:
|
||||
msg = convert_to_ros_msg(self.msg_type, value)
|
||||
self.publisher_.publish(msg)
|
||||
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
# self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
except Exception as e:
|
||||
self.node.lab_logger().error(
|
||||
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
|
||||
@@ -361,6 +377,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources import Plate
|
||||
|
||||
# 物料传输到对应的node节点
|
||||
client = self._resource_clients["c2s_update_resource_tree"]
|
||||
request = SerialCommand.Request()
|
||||
@@ -388,33 +405,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
|
||||
parent_resource = None
|
||||
if bind_parent_id != self.node_name:
|
||||
parent_resource = self.resource_tracker.figure_resource(
|
||||
{"name": bind_parent_id}
|
||||
)
|
||||
parent_resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
|
||||
for r in rts.root_nodes:
|
||||
# noinspection PyUnresolvedReferences
|
||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||
else:
|
||||
for r in rts.root_nodes:
|
||||
r.res_content.parent_uuid = self.uuid
|
||||
|
||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
|
||||
rts_plr_instances = rts.to_plr_resources()
|
||||
if len(rts.root_nodes) == 1 and isinstance(rts_plr_instances[0], RegularContainer):
|
||||
# noinspection PyTypeChecker
|
||||
container_instance: RegularContainer = rts.root_nodes[0]
|
||||
found_resources = self.resource_tracker.figure_resource(
|
||||
{"id": container_instance.name}, try_mode=True
|
||||
)
|
||||
container_instance: RegularContainer = rts_plr_instances[0]
|
||||
found_resources = self.resource_tracker.figure_resource({"name": container_instance.name}, try_mode=True)
|
||||
if not len(found_resources):
|
||||
self.resource_tracker.add_resource(container_instance)
|
||||
logger.info(f"添加物料{container_instance.name}到资源跟踪器")
|
||||
else:
|
||||
assert (
|
||||
len(found_resources) == 1
|
||||
), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||
assert len(found_resources) == 1, f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||
found_resource = found_resources[0]
|
||||
if isinstance(found_resource, RegularContainer):
|
||||
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):
|
||||
raise ValueError("已不支持 字典 版本的RegularContainer")
|
||||
else:
|
||||
@@ -422,14 +433,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
||||
)
|
||||
# noinspection PyUnresolvedReferences
|
||||
request.command = json.dumps({
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts.dump(),
|
||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "",
|
||||
"first_add": False,
|
||||
},
|
||||
})
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts.dump(),
|
||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else self.uuid,
|
||||
"first_add": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
plr_instances = rts.to_plr_resources()
|
||||
@@ -471,7 +484,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
||||
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
||||
self.lab_logger().warning(f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个")
|
||||
self.lab_logger().warning(
|
||||
f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个"
|
||||
)
|
||||
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
@@ -490,9 +505,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||
input_wells
|
||||
).dump()
|
||||
res.response = json.dumps(final_response)
|
||||
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
|
||||
if (
|
||||
issubclass(parent_resource.__class__, Deck)
|
||||
and hasattr(parent_resource, "assign_child_at_slot")
|
||||
and "slot" in other_calling_param
|
||||
):
|
||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||
parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
|
||||
else:
|
||||
@@ -507,14 +528,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
||||
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
||||
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
||||
request.command = json.dumps({
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts_with_parent.dump(),
|
||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||
"first_add": False,
|
||||
},
|
||||
})
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts_with_parent.dump(),
|
||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||
"first_add": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
@@ -811,7 +834,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
}
|
||||
|
||||
def _handle_update(
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]],
|
||||
tree_set: ResourceTreeSet,
|
||||
additional_add_params: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
"""
|
||||
处理资源更新操作的内部函数
|
||||
@@ -836,7 +861,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
|
||||
not_same_parent = (
|
||||
original_parent_resource_uuid != target_parent_resource_uuid
|
||||
and original_parent_resource is not None
|
||||
)
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
parent_appended = False
|
||||
@@ -872,8 +900,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
else:
|
||||
# 判断是否变更了resource_site,重新登记
|
||||
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
||||
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
|
||||
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
||||
sites = (
|
||||
original_instance.parent.sites
|
||||
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||
else None
|
||||
)
|
||||
site_names = (
|
||||
list(original_instance.parent._ordering.keys())
|
||||
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||
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]
|
||||
@@ -910,9 +946,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action = i.get("action") # remove, add, update
|
||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||
self.lab_logger().trace(
|
||||
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
||||
)
|
||||
self.lab_logger().trace(f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}")
|
||||
tree_set = None
|
||||
if action in ["add", "update"]:
|
||||
tree_set = await self.get_resource(
|
||||
@@ -939,9 +973,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||
) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "update":
|
||||
@@ -961,9 +999,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||
) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "remove":
|
||||
@@ -1333,7 +1375,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
else:
|
||||
uuid_indices.append((idx, unilabos_uuid, resource_data))
|
||||
@@ -1346,7 +1388,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
||||
plr_resource = plr_resources[i]
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
|
||||
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
||||
@@ -1354,7 +1396,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 通过资源跟踪器获取本地实例
|
||||
final_resources = queried_resources if is_sequence else queried_resources[0]
|
||||
if not is_sequence:
|
||||
plr = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
|
||||
plr = self.resource_tracker.figure_resource(
|
||||
{"name": final_resources.name}, try_mode=False
|
||||
)
|
||||
# 保留unilabos_extra
|
||||
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
||||
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
|
||||
@@ -1393,8 +1437,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = True
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
error(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||
)
|
||||
trace(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
|
||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
@@ -1414,9 +1462,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||
)
|
||||
trace(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
|
||||
@@ -1483,11 +1533,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if isinstance(rs, list):
|
||||
for r in rs:
|
||||
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:
|
||||
res = self.resource_tracker.parent_resource(rs)
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
if res is None:
|
||||
res = rs
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
|
||||
# 使用新的资源树接口
|
||||
if unique_resources:
|
||||
@@ -1539,20 +1596,39 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
# 获取 unilabos 系统参数
|
||||
unilabos_param: Dict[str, Any] = target[JSON_UNILABOS_PARAM]
|
||||
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(
|
||||
function
|
||||
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
|
||||
# 处理 ResourceSlot 类型参数
|
||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
||||
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||
for arg in args_list:
|
||||
arg_name = arg["name"]
|
||||
arg_type = arg["type"]
|
||||
|
||||
# 跳过不在 function_args 中的参数
|
||||
if arg_name not in function_args:
|
||||
# 处理 sample_uuids 参数注入
|
||||
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||
# 将 material uuid 转换为 resource 实例
|
||||
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||
resolved_sample_uuids: Dict[str, Any] = {}
|
||||
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||
if material_uuid and self.resource_tracker:
|
||||
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||
else:
|
||||
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||
self.lab_logger().debug(
|
||||
f"[JsonCommand] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 处理单个 ResourceSlot
|
||||
@@ -1582,6 +1658,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||
|
||||
# todo: 默认反报送
|
||||
return function(**function_args)
|
||||
except KeyError as ex:
|
||||
raise JsonCommandInitError(
|
||||
@@ -1601,21 +1678,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
raise ValueError("至少需要提供一个 UUID")
|
||||
|
||||
uuids_list = list(uuids)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||
SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
|
||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||
timeout = 30.0
|
||||
elapsed = 0.0
|
||||
while not future.done() and elapsed < timeout:
|
||||
time.sleep(0.05)
|
||||
elapsed += 0.05
|
||||
time.sleep(0.02)
|
||||
elapsed += 0.02
|
||||
|
||||
if not future.done():
|
||||
raise Exception(f"资源查询超时: {uuids_list}")
|
||||
@@ -1666,6 +1745,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
# 获取 unilabos 系统参数
|
||||
unilabos_param: Dict[str, Any] = target.get(JSON_UNILABOS_PARAM, {})
|
||||
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(
|
||||
@@ -1675,14 +1757,30 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
function
|
||||
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
||||
|
||||
# 处理 ResourceSlot 类型参数
|
||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
||||
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||
for arg in args_list:
|
||||
arg_name = arg["name"]
|
||||
arg_type = arg["type"]
|
||||
|
||||
# 跳过不在 function_args 中的参数
|
||||
if arg_name not in function_args:
|
||||
# 处理 sample_uuids 参数注入
|
||||
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||
# 将 material uuid 转换为 resource 实例
|
||||
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||
resolved_sample_uuids: Dict[str, Any] = {}
|
||||
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||
if material_uuid and self.resource_tracker:
|
||||
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||
else:
|
||||
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||
self.lab_logger().debug(
|
||||
f"[JsonCommandAsync] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 处理单个 ResourceSlot
|
||||
@@ -1960,7 +2058,9 @@ class ROS2DeviceNode:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode")
|
||||
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(
|
||||
target=run_event_loop, daemon=True, name="ROS2DeviceNode"
|
||||
)
|
||||
ROS2DeviceNode._asyncio_loop_thread.start()
|
||||
logger.info(f"循环线程已启动")
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import collections
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from action_msgs.msg import GoalStatus
|
||||
from geometry_msgs.msg import Point
|
||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||
from rclpy.service import Service
|
||||
from typing_extensions import TypedDict
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos_msgs.srv import (
|
||||
ResourceAdd,
|
||||
@@ -23,10 +23,20 @@ from unilabos_msgs.srv import (
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import initialize_resource
|
||||
from unilabos.resources.registry import add_schema
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
RETURN_UNILABOS_SAMPLES,
|
||||
JSON_UNILABOS_PARAM,
|
||||
PARAM_SAMPLE_UUIDS,
|
||||
)
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
get_msg_type,
|
||||
@@ -37,17 +47,10 @@ from unilabos.ros.msgs.message_converter import (
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
)
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.exception import DeviceClassInvalid
|
||||
from unilabos.utils.log import warning
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
@@ -755,6 +758,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
item: "QueueItem",
|
||||
action_type: str,
|
||||
action_kwargs: Dict[str, Any],
|
||||
sample_material: Dict[str, str],
|
||||
server_info: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -772,14 +776,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
if action_name.startswith("auto-"):
|
||||
action_name = action_name[5:]
|
||||
action_id = f"/devices/{device_id}/_execute_driver_command"
|
||||
action_kwargs = {
|
||||
"string": json.dumps(
|
||||
{
|
||||
"function_name": action_name,
|
||||
"function_args": action_kwargs,
|
||||
}
|
||||
)
|
||||
json_command: Dict[str, Any] = {
|
||||
"function_name": action_name,
|
||||
"function_args": action_kwargs,
|
||||
JSON_UNILABOS_PARAM: {
|
||||
PARAM_SAMPLE_UUIDS: sample_material,
|
||||
},
|
||||
}
|
||||
action_kwargs = {"string": json.dumps(json_command)}
|
||||
if action_type.startswith("UniLabJsonCommandAsync"):
|
||||
action_id = f"/devices/{device_id}/_execute_driver_command_async"
|
||||
else:
|
||||
@@ -790,24 +794,9 @@ class HostNode(BaseROS2DeviceNode):
|
||||
raise ValueError(f"ActionClient {action_id} not found.")
|
||||
|
||||
action_client: ActionClient = self._action_clients[action_id]
|
||||
|
||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
||||
def assign_sample_id(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "sample_uuid" in obj:
|
||||
obj["sample_id"] = obj["sample_uuid"]
|
||||
obj.pop("sample_uuid")
|
||||
for k, v in obj.items():
|
||||
if k != "unilabos_extra":
|
||||
assign_sample_id(v)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
assign_sample_id(item)
|
||||
|
||||
assign_sample_id(action_kwargs)
|
||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
# self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {action_kwargs}")
|
||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
action_client.wait_for_server()
|
||||
@@ -867,14 +856,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
# 适配后端的一些额外处理
|
||||
return_value = return_info.get("return_value")
|
||||
if isinstance(return_value, dict):
|
||||
unilabos_samples = return_value.pop("unilabos_samples", None)
|
||||
unilabos_samples = return_value.pop(RETURN_UNILABOS_SAMPLES, None)
|
||||
if isinstance(unilabos_samples, list) and unilabos_samples:
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): "
|
||||
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 ''}"
|
||||
)
|
||||
return_info["unilabos_samples"] = unilabos_samples
|
||||
return_info["samples"] = unilabos_samples
|
||||
suc = return_info.get("suc", False)
|
||||
if not suc:
|
||||
status = "failed"
|
||||
@@ -1180,7 +1169,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
"""
|
||||
更新节点信息回调
|
||||
"""
|
||||
# self.lab_logger().info(f"[Host Node] Node info update request received: {request}")
|
||||
self.lab_logger().trace(f"[Host Node] Node info update request received: {request}")
|
||||
try:
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.app.web.client import HTTPClient, http_client
|
||||
|
||||
@@ -340,6 +340,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
|
||||
# 获取父资源
|
||||
res = self.resource_tracker.parent_resource(plr)
|
||||
if res is None:
|
||||
res = plr
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
|
||||
@@ -52,7 +52,8 @@ class DeviceClassCreator(Generic[T]):
|
||||
if self.device_instance is not None:
|
||||
for c in self.children:
|
||||
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:
|
||||
"""
|
||||
@@ -119,7 +120,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
# return resource, source_type
|
||||
|
||||
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:
|
||||
"""
|
||||
递归处理资源引用,替换_resource_child_name对应的资源
|
||||
@@ -164,6 +165,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
states[prefix_path] = resource_instance.serialize_all_state()
|
||||
return serialized
|
||||
else:
|
||||
processed_child_names[child_name] = resource_instance
|
||||
self.resource_tracker.add_resource(resource_instance)
|
||||
# 立即设置UUID,state已经在resource_ulab_to_plr中处理过了
|
||||
if name_to_uuid:
|
||||
@@ -182,12 +184,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
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
|
||||
|
||||
elif isinstance(data, list):
|
||||
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)
|
||||
]
|
||||
|
||||
@@ -234,7 +236,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
# 首先处理资源引用
|
||||
states = {}
|
||||
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:
|
||||
@@ -270,7 +272,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
arg_value = spec_args[param_name].annotation
|
||||
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(f"PyLabRobot创建实例失败: {e}")
|
||||
@@ -342,9 +349,10 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
|
||||
try:
|
||||
# 创建实例,额外补充一个给protocol node的字段,后面考虑取消
|
||||
data["children"] = self.children
|
||||
for child in self.children:
|
||||
if child.res_content.type != "device":
|
||||
self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||
# super(WorkstationNodeCreator, self).create_instance(data)的时候会attach
|
||||
# for child in self.children:
|
||||
# if child.res_content.type != "device":
|
||||
# self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||
deck_dict = data.get("deck")
|
||||
if deck_dict:
|
||||
from pylabrobot.resources import Deck, Resource
|
||||
|
||||
795
unilabos/test/experiments/prcxi_9320_slim.json
Normal file
795
unilabos/test/experiments/prcxi_9320_slim.json
Normal file
@@ -0,0 +1,795 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PRCXI",
|
||||
"name": "PRCXI",
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 562,
|
||||
"height": 394,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"axis": "Left",
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
},
|
||||
"host": "10.20.30.184",
|
||||
"port": 9999,
|
||||
"debug": true,
|
||||
"setup": true,
|
||||
"is_9320": true,
|
||||
"timeout": 10,
|
||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||
"simulator": true,
|
||||
"channel_num": 2
|
||||
},
|
||||
"data": {
|
||||
"reset_ok": true
|
||||
},
|
||||
"schema": {},
|
||||
"description": "",
|
||||
"model": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 240,
|
||||
"z": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"name": "PRCXI_Deck",
|
||||
|
||||
"children": [],
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck",
|
||||
"barcode": null
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T1",
|
||||
"name": "T1",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"name": "T2",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T2",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"name": "T3",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T3",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"name": "T4",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T4",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"name": "T5",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T5",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"name": "T6",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T6",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"name": "T7",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T7",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"name": "T8",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T8",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"name": "T9",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T9",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"name": "T10",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T10",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"name": "T11",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T11",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"name": "T12",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T12",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"name": "T13",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T13",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"name": "T14",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T14",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"name": "T15",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T15",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"name": "T16",
|
||||
"children": [],
|
||||
"parent": "PRCXI_Deck",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T16",
|
||||
"visible": true,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
@@ -27,6 +27,7 @@ __all__ = [
|
||||
|
||||
from ast import Constant
|
||||
|
||||
from unilabos.resources.resource_tracker import PARAM_SAMPLE_UUIDS
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.decorator import is_not_action
|
||||
|
||||
@@ -341,13 +342,18 @@ class ImportManager:
|
||||
result["action_methods"][method_name] = method_info
|
||||
return result
|
||||
|
||||
def _analyze_method_signature(self, method) -> Dict[str, Any]:
|
||||
def _analyze_method_signature(self, method, skip_unilabos_params: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
分析方法签名,提取具体的命名参数信息
|
||||
|
||||
注意:此方法会跳过*args和**kwargs,只提取具体的命名参数
|
||||
这样可以确保通过**dict方式传参时的准确性
|
||||
|
||||
Args:
|
||||
method: 要分析的方法
|
||||
skip_unilabos_params: 是否跳过 unilabos 系统参数(如 sample_uuids),
|
||||
registry 补全时为 True,JsonCommand 执行时为 False
|
||||
|
||||
示例用法:
|
||||
method_info = self._analyze_method_signature(some_method)
|
||||
params = {"param1": "value1", "param2": "value2"}
|
||||
@@ -368,6 +374,10 @@ class ImportManager:
|
||||
if param.kind == param.VAR_KEYWORD: # **kwargs
|
||||
continue
|
||||
|
||||
# 跳过 sample_uuids 参数(由系统自动注入,registry 补全时跳过)
|
||||
if skip_unilabos_params and param_name == PARAM_SAMPLE_UUIDS:
|
||||
continue
|
||||
|
||||
is_required = param.default == inspect.Parameter.empty
|
||||
if is_required:
|
||||
num_required += 1
|
||||
@@ -563,6 +573,9 @@ class ImportManager:
|
||||
for i, arg in enumerate(node.args.args):
|
||||
if arg.arg == "self":
|
||||
continue
|
||||
# 跳过 sample_uuids 参数(由系统自动注入)
|
||||
if arg.arg == PARAM_SAMPLE_UUIDS:
|
||||
continue
|
||||
arg_info = {
|
||||
"name": arg.arg,
|
||||
"type": None,
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
第一步: 按 slot 去重创建 create_resource 节点(创建板子)
|
||||
--------------------------------------------------------------------------------
|
||||
- 首先创建一个 Group 节点(type="Group", minimized=true),用于包含所有 create_resource 节点
|
||||
- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子
|
||||
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||
- 生成参数:
|
||||
res_id: plate_slot_{slot}
|
||||
device_id: /PRCXI
|
||||
@@ -29,11 +31,13 @@
|
||||
- 输出端口: labware(用于连接 set_liquid_from_plate)
|
||||
- 控制流: create_resource 之间通过 ready 端口串联
|
||||
|
||||
示例: slot=1, slot=4 -> 创建 2 个 create_resource 节点
|
||||
示例: slot=1, slot=4 -> 创建 1 个 Group + 2 个 create_resource 节点
|
||||
|
||||
第二步: 为每个 reagent 创建 set_liquid_from_plate 节点(设置液体)
|
||||
--------------------------------------------------------------------------------
|
||||
- 首先创建一个 Group 节点(type="Group", minimized=true),用于包含所有 set_liquid_from_plate 节点
|
||||
- 遍历所有 reagent,为每个试剂创建 set_liquid_from_plate 节点
|
||||
- 所有 set_liquid_from_plate 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||
- 生成参数:
|
||||
plate: [](通过连接传递,来自 create_resource 的 labware)
|
||||
well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组)
|
||||
@@ -56,7 +60,11 @@
|
||||
==================== 连接关系图 ====================
|
||||
|
||||
控制流 (ready 端口串联):
|
||||
create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ...
|
||||
- create_resource 之间: 无 ready 连接
|
||||
- set_liquid_from_plate 之间: 无 ready 连接
|
||||
- create_resource 与 set_liquid_from_plate 之间: 无 ready 连接
|
||||
- transfer_liquid 之间: 通过 ready 端口串联
|
||||
transfer_liquid_1 -> transfer_liquid_2 -> transfer_liquid_3 -> ...
|
||||
|
||||
物料流:
|
||||
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
||||
@@ -76,6 +84,13 @@ transfer_liquid:
|
||||
输入: sources -> sources_identifier, targets -> targets_identifier
|
||||
输出: sources -> sources_out, targets -> targets_out
|
||||
|
||||
==================== 设备名配置 (device_name) ====================
|
||||
|
||||
每个节点都有 device_name 字段,指定在哪个设备上执行:
|
||||
- create_resource: device_name = "host_node"(固定)
|
||||
- set_liquid_from_plate: device_name = "PRCXI"(可配置,见 DEVICE_NAME_DEFAULT)
|
||||
- transfer_liquid 等动作: device_name = "PRCXI"(可配置,见 DEVICE_NAME_DEFAULT)
|
||||
|
||||
==================== 校验规则 ====================
|
||||
|
||||
- 检查 sources/targets 是否在 reagent 中定义
|
||||
@@ -97,6 +112,13 @@ Json = Dict[str, Any]
|
||||
|
||||
# ==================== 默认配置 ====================
|
||||
|
||||
# 设备名配置
|
||||
DEVICE_NAME_HOST = "host_node" # create_resource 固定在 host_node 上执行
|
||||
DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动作的默认设备名
|
||||
|
||||
# 节点类型
|
||||
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
||||
|
||||
# create_resource 节点默认参数
|
||||
CREATE_RESOURCE_DEFAULTS = {
|
||||
"device_id": "/PRCXI",
|
||||
@@ -367,9 +389,23 @@ def build_protocol_graph(
|
||||
"res_id": res_id,
|
||||
}
|
||||
|
||||
# 创建 Group 节点,包含所有 create_resource 节点
|
||||
group_node_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
group_node_id,
|
||||
name="Resources Group",
|
||||
type="Group",
|
||||
parent_uuid="",
|
||||
lab_node_type="Device",
|
||||
template_name="",
|
||||
resource_name="",
|
||||
footer="",
|
||||
minimized=True,
|
||||
param=None,
|
||||
)
|
||||
|
||||
# 为每个唯一的 slot 创建 create_resource 节点
|
||||
res_index = 0
|
||||
last_create_resource_id = None
|
||||
for slot, info in slots_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
res_id = info["res_id"]
|
||||
@@ -383,6 +419,10 @@ def build_protocol_graph(
|
||||
description=f"Create plate on slot {slot}",
|
||||
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, # 折叠显示
|
||||
param={
|
||||
"res_id": res_id,
|
||||
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
||||
@@ -394,14 +434,25 @@ def build_protocol_graph(
|
||||
)
|
||||
slot_to_create_resource[slot] = node_id
|
||||
|
||||
# create_resource 之间通过 ready 串联
|
||||
if last_create_resource_id is not None:
|
||||
G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready")
|
||||
last_create_resource_id = node_id
|
||||
# create_resource 之间不需要 ready 连接
|
||||
|
||||
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
||||
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
||||
set_liquid_group_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
set_liquid_group_id,
|
||||
name="SetLiquid Group",
|
||||
type="Group",
|
||||
parent_uuid="",
|
||||
lab_node_type="Device",
|
||||
template_name="",
|
||||
resource_name="",
|
||||
footer="",
|
||||
minimized=True,
|
||||
param=None,
|
||||
)
|
||||
|
||||
set_liquid_index = 0
|
||||
last_set_liquid_id = last_create_resource_id # set_liquid_from_plate 连接在 create_resource 之后
|
||||
|
||||
for labware_id, item in labware_info.items():
|
||||
# 跳过 Tip/Rack 类型
|
||||
@@ -430,6 +481,10 @@ def build_protocol_graph(
|
||||
description=f"Set liquid: {labware_id}",
|
||||
lab_node_type="Reagent",
|
||||
footer="set_liquid_from_plate-liquid_handler.prcxi",
|
||||
device_name=DEVICE_NAME_DEFAULT,
|
||||
type=NODE_TYPE_DEFAULT,
|
||||
parent_uuid=set_liquid_group_id, # 指向 Group 节点
|
||||
minimized=True, # 折叠显示
|
||||
param={
|
||||
"plate": [], # 通过连接传递
|
||||
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
|
||||
@@ -438,10 +493,7 @@ def build_protocol_graph(
|
||||
},
|
||||
)
|
||||
|
||||
# ready 连接:上一个节点 -> set_liquid_from_plate
|
||||
if last_set_liquid_id is not None:
|
||||
G.add_edge(last_set_liquid_id, node_id, source_port="ready", target_port="ready")
|
||||
last_set_liquid_id = node_id
|
||||
# set_liquid_from_plate 之间不需要 ready 连接
|
||||
|
||||
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate
|
||||
create_res_node_id = slot_to_create_resource.get(slot)
|
||||
@@ -451,7 +503,8 @@ def build_protocol_graph(
|
||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||
|
||||
last_control_node_id = last_set_liquid_id
|
||||
# transfer_liquid 之间通过 ready 串联,从 None 开始
|
||||
last_control_node_id = None
|
||||
|
||||
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
||||
INPUT_PORT_MAPPING = {
|
||||
@@ -544,9 +597,11 @@ def build_protocol_graph(
|
||||
if param_key in params:
|
||||
params[param_key] = []
|
||||
|
||||
# 更新 step 的 param 和 footer
|
||||
# 更新 step 的 param、footer、device_name 和 type
|
||||
step_copy = step.copy()
|
||||
step_copy["param"] = params
|
||||
step_copy["device_name"] = DEVICE_NAME_DEFAULT # 动作节点使用默认设备名
|
||||
step_copy["type"] = NODE_TYPE_DEFAULT # 节点类型
|
||||
|
||||
# 如果有警告,修改 footer 添加警告标记(警告放前面)
|
||||
if warnings:
|
||||
|
||||
Reference in New Issue
Block a user